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.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
namespace Tiku.Api.Contracts;
@@ -9,6 +10,8 @@ namespace Tiku.Api.Contracts;
/// </summary>
public sealed class PasswordLoginDto
{
[Required]
public AuthRealm? Realm { get; set; }
/// <summary>
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
/// </summary>
@@ -17,18 +20,21 @@ public sealed class PasswordLoginDto
public string? TenantCode { get; set; }
/// <summary>
/// 手机号,建议前端提交规范化后的中国大陆手机号
/// 账号标识。tenant 可使用手机号platform 可使用邮箱或用户名
/// </summary>
[Required]
[StringLength(320)]
[Description("账号标识。tenant 可使用手机号platform 可使用邮箱或用户名。")]
public string? Identifier { get; set; }
[StringLength(32)]
[Description("手机号,建议前端提交规范化后的中国大陆手机号。")]
public string Phone { get; set; } = string.Empty;
[Description("兼容手机号字段;新客户端应使用 identifier。")]
public string? Phone { get; set; }
/// <summary>
/// 用户密码。
/// </summary>
[Required]
[StringLength(128, MinimumLength = 6)]
[StringLength(128, MinimumLength = 10)]
[Description("用户密码。")]
public string Password { get; set; } = string.Empty;
}
@@ -38,6 +44,8 @@ public sealed class PasswordLoginDto
/// </summary>
public sealed class SmsLoginDto
{
[Required]
public AuthRealm? Realm { get; set; }
/// <summary>
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
/// </summary>
@@ -62,11 +70,29 @@ public sealed class SmsLoginDto
public string Code { get; set; } = string.Empty;
}
public sealed class SendSmsCodeDto
{
[Required]
public AuthRealm? Realm { get; set; }
[StringLength(100)]
public string? TenantCode { get; set; }
[Required]
[StringLength(32)]
public string Phone { get; set; } = string.Empty;
[StringLength(256)]
public string? DeviceId { get; set; }
}
/// <summary>
/// OAuth code 登录请求。
/// </summary>
public sealed class OAuthCodeDto
{
[Required]
public AuthRealm? Realm { get; set; }
/// <summary>
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
/// </summary>
@@ -135,10 +161,12 @@ public sealed class AuthenticatedUserDto
/// </summary>
public string? Name { get; init; }
public AuthRealm Realm { get; init; }
/// <summary>
/// 当前登录租户成员摘要。
/// </summary>
public TenantMembershipSummary Tenant { get; init; } = default!;
public TenantMembershipSummary? Tenant { get; init; }
/// <summary>
/// access token 和 refresh token。
@@ -153,8 +181,52 @@ public sealed class AuthenticatedUserDto
Phone = user.Phone,
Email = user.Email,
Name = user.Name,
Realm = user.Realm,
Tenant = user.Tenant,
Tokens = user.Tokens
};
}
}
public sealed class AuthenticationResultDto
{
public AuthenticationStatus Status { get; init; }
public AuthenticatedUserDto? User { get; init; }
public string? ChallengeToken { get; init; }
public DateTimeOffset? ChallengeExpiresAt { get; init; }
public static AuthenticationResultDto FromApplication(AuthenticationResult result) => new()
{
Status = result.Status,
User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User),
ChallengeToken = result.ChallengeToken,
ChallengeExpiresAt = result.ChallengeExpiresAt
};
}
public sealed class MfaChallengeDto
{
[Required]
[StringLength(2048)]
public string ChallengeToken { get; set; } = string.Empty;
[StringLength(64)]
public string? Code { get; set; }
}
public sealed class MfaConfirmDto
{
public AuthenticationResultDto Authentication { get; init; } = default!;
public IReadOnlyList<string> RecoveryCodes { get; init; } = [];
}
public sealed class RequiredPasswordChangeDto
{
[Required]
[StringLength(2048)]
public string ChallengeToken { get; set; } = string.Empty;
[Required]
[StringLength(128, MinimumLength = 10)]
public string NewPassword { get; set; } = string.Empty;
}

View File

@@ -97,19 +97,6 @@ public sealed class TenantAdminAuditLogQueryDto
}
}
public sealed class TenantAdminRoleTemplateQueryDto
{
public string? Status { get; set; }
[Range(1, 500)]
public int? Limit { get; set; }
public TenantAdminRoleTemplateFilter ToFilter()
{
return new TenantAdminRoleTemplateFilter(Status, Limit);
}
}
public sealed class TenantAdminBadgeQueryDto
{
public string? Category { get; set; }
@@ -324,13 +311,11 @@ public sealed class UpsertTenantAdminMemberDto
public TenantAdminUserLookupDto User { get; set; } = new();
public string? Role { get; set; }
public string? Status { get; set; }
public Guid? RoleTemplateId { get; set; }
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public string? PrimaryRole { get; set; }
public UpsertTenantAdminMemberCommand ToCommand()
{
return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, RoleTemplateId, Permissions, PrimaryRole);
return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, PrimaryRole);
}
}
@@ -340,45 +325,6 @@ public sealed class DisableTenantAdminMemberDto
public Guid MembershipId { get; set; }
}
public sealed class UpsertTenantAdminRoleTemplateDto
{
public Guid? Id { get; set; }
public string? Code { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public string? BaseRole { get; set; }
public string? Status { get; set; }
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public JsonElement MenuPermissions { get; set; } = JsonDefaults.Object();
public JsonElement ModulePermissions { get; set; } = JsonDefaults.Object();
public JsonElement FieldPermissions { get; set; } = JsonDefaults.Object();
public JsonElement DataScope { get; set; } = JsonDefaults.Object();
public int? Order { get; set; }
public UpsertTenantAdminRoleTemplateCommand ToCommand()
{
return new UpsertTenantAdminRoleTemplateCommand(
Id,
Code,
Name,
Description,
BaseRole,
Status,
Permissions,
MenuPermissions,
ModulePermissions,
FieldPermissions,
DataScope,
Order);
}
}
public sealed class DisableTenantAdminRoleTemplateDto
{
[Required]
public Guid RoleTemplateId { get; set; }
}
public sealed class UpsertTenantBrandingDto
{
public required string BrandName { get; set; }

View File

@@ -1,10 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Content;
using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
@@ -13,97 +17,144 @@ namespace Tiku.Api.Controllers;
[Produces("application/json")]
public sealed class AuthController(
IAuthService authService,
ISessionService sessionService,
ISmsVerificationService smsVerificationService,
IAuthSessionStore sessionStore,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
ITenantDirectory tenantDirectory,
ICurrentUser currentUser,
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
{
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("sms/send")]
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<SmsSendResult>> SendSmsCode(
[FromBody] SendSmsCodeDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
if (realm != AuthRealm.Tenant)
{
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
}
var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
var result = await smsVerificationService.CreateCodeAsync(
new SendSmsCodeRequest(
tenantId,
request.Phone,
SmsPurpose.Login,
GetIpAddress(),
Request.Headers.UserAgent.ToString(),
request.DeviceId),
cancellationToken);
return Accepted(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("login/password")]
[EndpointSummary("手机号密码登录")]
[EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithPassword(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithPassword(
[FromBody] PasswordLoginDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var identifier = request.Identifier ?? request.Phone;
if (string.IsNullOrWhiteSpace(identifier))
{
throw new RequiredFieldException("identifier is required.");
}
var result = await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Phone,
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
identifier,
request.Password,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("login/sms")]
[EndpointSummary("短信验证码登录")]
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithSms(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithSms(
[FromBody] SmsLoginDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var result = await authService.LoginWithSmsAsync(
new SmsLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Phone,
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat")]
[EndpointSummary("微信网页 OAuth 登录")]
[EndpointDescription("使用微信网页授权 code 换取 openid/unionidupsert 用户身份并创建应用会话。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatWeb(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatWeb(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat-miniapp")]
[EndpointSummary("微信小程序登录")]
[EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_keyupsert 用户身份并创建应用会话。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatMiniApp(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatMiniApp(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
@@ -142,14 +193,135 @@ public sealed class AuthController(
return NoContent();
}
[HttpPost("logout-all")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
{
return Unauthorized();
}
await authService.LogoutAllAsync(userId, cancellationToken);
return NoContent();
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/setup")]
public async Task<ActionResult<MfaSetupResult>> SetupTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.SetupTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, null, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/confirm")]
public async Task<ActionResult<MfaConfirmDto>> ConfirmTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.ConfirmTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(new MfaConfirmDto
{
Authentication = AuthenticationResultDto.FromApplication(result.Authentication),
RecoveryCodes = result.RecoveryCodes
});
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/verify")]
public async Task<ActionResult<AuthenticationResultDto>> VerifyTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.VerifyTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("password/change-required")]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
[FromBody] RequiredPasswordChangeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.ChangeRequiredPasswordAsync(
new PasswordChangeChallengeRequest(
request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticationResultDto.FromApplication(result));
}
private void ResolveRefreshTokenTenant(string refreshToken)
{
if (!sessionService.TryParseRefreshToken(refreshToken, out var locator))
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
{
return;
}
tenantContextInitializer.Initialize(locator.TenantId, null, TenantResolutionSource.RefreshToken);
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, StringSplitOptions.None);
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()
@@ -157,8 +329,22 @@ public sealed class AuthController(
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
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) &&
@@ -189,4 +375,17 @@ public sealed class AuthController(
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.");
}
}
}

View File

@@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers;
[ApiController]
[Route("api/backoffice/tenant/jobs")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantJobManage)]
public sealed class BackgroundJobsController(
IBackgroundJobService backgroundJobService,
ITenantContext tenantContext) : ControllerBase

View File

@@ -10,113 +10,117 @@ namespace Tiku.Api.Controllers;
[Route("api/backoffice")]
public sealed class BackofficeController(
IBackofficeService backofficeService,
ICurrentUser currentUser,
ITenantContext tenantContext) : ControllerBase
ICurrentAccessContext currentAccessContext) : ControllerBase
{
[HttpGet("tenant/ui-bootstrap")]
[Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeUiBootstrap>> GetTenantUiBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetTenantUiBootstrapAsync(
await currentAccessContext.GetAsync(cancellationToken),
cancellationToken));
}
[HttpGet("tenant/bootstrap")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetTenantBootstrapAsync(ResolveTenantActor(), cancellationToken));
return Ok(await backofficeService.GetTenantBootstrapAsync(await ResolveTenantActorAsync(cancellationToken), cancellationToken));
}
[HttpGet("platform/ui-bootstrap")]
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeUiBootstrap>> GetPlatformUiBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetPlatformUiBootstrapAsync(
await currentAccessContext.GetAsync(cancellationToken),
cancellationToken));
}
[HttpPost("tenant/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
UpsertBackofficeRoleDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.UpsertTenantRoleAsync(ResolveTenantActor(), request.ToCommand(), cancellationToken));
return Ok(await backofficeService.UpsertTenantRoleAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(), cancellationToken));
}
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
Guid roleId,
ReplaceRoleBindingsDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(ResolveTenantActor(), request.ToCommand(roleId), cancellationToken));
return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
}
[HttpPut("tenant/users/{userId:guid}/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ReplaceTenantUserRoles(
Guid userId,
ReplaceUserRolesDto request,
CancellationToken cancellationToken)
{
await backofficeService.ReplaceTenantUserRolesAsync(ResolveTenantActor(), request.ToCommand(userId), cancellationToken);
await backofficeService.ReplaceTenantUserRolesAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
return NoContent();
}
[HttpGet("platform/bootstrap")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetPlatformBootstrapAsync(ResolvePlatformActor(), cancellationToken));
return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolvePlatformActorAsync(cancellationToken), cancellationToken));
}
[HttpPost("platform/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
UpsertBackofficeRoleDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.UpsertPlatformRoleAsync(ResolvePlatformActor(), request.ToCommand(), cancellationToken));
return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(), cancellationToken));
}
[HttpPut("platform/roles/{roleId:guid}/bindings")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
Guid roleId,
ReplaceRoleBindingsDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(ResolvePlatformActor(), request.ToCommand(roleId), cancellationToken));
return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
}
[HttpPut("platform/users/{userId:guid}/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ReplacePlatformUserRoles(
Guid userId,
ReplaceUserRolesDto request,
CancellationToken cancellationToken)
{
await backofficeService.ReplacePlatformUserRolesAsync(ResolvePlatformActor(), request.ToCommand(userId), cancellationToken);
await backofficeService.ReplacePlatformUserRolesAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
return NoContent();
}
private BackofficeActor ResolveTenantActor()
private async Task<BackofficeActor> ResolveTenantActorAsync(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId || tenantContext.TenantId is not { } tenantId)
{
throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
}
return new BackofficeActor(userId, tenantId, IsPlatformAdmin());
return BackofficeActor.FromTenantAccess(await currentAccessContext.GetAsync(cancellationToken));
}
private BackofficeActor ResolvePlatformActor()
private async Task<BackofficeActor> ResolvePlatformActorAsync(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
{
throw new InvalidOperationException("Platform backoffice actor was not resolved.");
}
return new BackofficeActor(userId, tenantContext.TenantId, IsPlatformAdmin());
}
private bool IsPlatformAdmin()
{
return string.Equals(currentUser.TenantRole, "PlatformAdmin", StringComparison.OrdinalIgnoreCase);
return BackofficeActor.FromPlatformAccess(await currentAccessContext.GetAsync(cancellationToken));
}
}

View File

@@ -7,7 +7,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCommissionManage)]
[Produces("application/json")]
[Route("api/commission")]
public sealed class CommissionController(

View File

@@ -7,7 +7,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[Produces("application/json")]
[Route("api/crm")]
public sealed class CrmController(

View File

@@ -95,7 +95,7 @@ public sealed class ReferralController(
}
[HttpGet("stats")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐人个人统计")]
[ProducesResponseType<ReferralStatsItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralStatsItem>> Stats(
@@ -109,7 +109,7 @@ public sealed class ReferralController(
}
[HttpGet("sales-stats")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询销售推荐统计排行")]
[ProducesResponseType<ReferralList<ReferralStatsItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralList<ReferralStatsItem>>> SalesStats(
@@ -123,7 +123,7 @@ public sealed class ReferralController(
}
[HttpGet("conversion-report")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐转化报告")]
[ProducesResponseType<ReferralConversionReport>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralConversionReport>> ConversionReport(
@@ -137,7 +137,7 @@ public sealed class ReferralController(
}
[HttpGet("sales-clients")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐人名下客户")]
[ProducesResponseType<ReferralList<ReferralLeadItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralList<ReferralLeadItem>>> SalesClients(
@@ -151,7 +151,7 @@ public sealed class ReferralController(
}
[HttpPost("manual-bind")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("人工调整学生推荐归属")]
[ProducesResponseType<ReferralBindResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralBindResult>> ManualBind(
@@ -165,7 +165,7 @@ public sealed class ReferralController(
}
[HttpGet("team")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐团队成员")]
[ProducesResponseType<ReferralList<ReferralTeamItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralList<ReferralTeamItem>>> Team(
@@ -179,7 +179,7 @@ public sealed class ReferralController(
}
[HttpPut("team")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("新增或更新推荐团队关系")]
[ProducesResponseType<ReferralTeamItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralTeamItem>> UpsertTeam(

View File

@@ -28,8 +28,7 @@ public sealed class SecurityDiagnosticsController(
{
return Ok(new
{
currentTenant.TenantId,
currentUser.TenantRole
currentTenant.TenantId
});
}
@@ -39,8 +38,7 @@ public sealed class SecurityDiagnosticsController(
{
return Ok(new
{
currentTenant.TenantId,
currentUser.TenantRole
currentTenant.TenantId
});
}
}

View File

@@ -21,7 +21,7 @@ public sealed class TaxonomyController(
}
[HttpPost]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantContentManage)]
public Task<TaxonomyNodeItem> Create(
CreateTaxonomyNodeDto request,
CancellationToken cancellationToken)

View File

@@ -10,7 +10,6 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Produces("application/json")]
[Route("api/tenant-admin")]
public sealed class TenantAdminDirectController(
@@ -19,6 +18,7 @@ public sealed class TenantAdminDirectController(
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("classes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户班级")]
[ProducesResponseType<TenantAdminClassList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminClassList>> GetClasses(
@@ -29,6 +29,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("classes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户班级")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> UpsertClass(
@@ -39,6 +40,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("classes/disable")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("停用租户班级")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> DisableClass(
@@ -49,6 +51,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("classes/members")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询班级成员")]
[ProducesResponseType<CatalogList<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminClassMemberItem>>> GetClassMembers(
@@ -59,6 +62,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("classes/members")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新班级成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> UpsertClassMember(
@@ -69,6 +73,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("classes/members/remove")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("移除班级成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> RemoveClassMember(
@@ -79,6 +84,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("students")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户学生")]
[ProducesResponseType<TenantAdminStudentList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminStudentList>> GetStudents(
@@ -89,6 +95,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("students")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户学生档案")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentItem>>> UpsertStudent(
@@ -99,6 +106,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("students/status")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("更新租户学生状态")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentStatusItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentStatusItem>>> UpdateStudentStatus(
@@ -109,6 +117,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("student-notes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询学生备注")]
[ProducesResponseType<CatalogList<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminStudentNoteItem>>> GetStudentNotes(
@@ -119,6 +128,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("student-notes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新学生备注")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentNoteItem>>> UpsertStudentNote(
@@ -129,6 +139,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("student-followups")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询学生跟进")]
[ProducesResponseType<CatalogList<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminStudentFollowupItem>>> GetStudentFollowups(
@@ -139,6 +150,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("student-followups")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新学生跟进")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentFollowupItem>>> UpsertStudentFollowup(
@@ -149,6 +161,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("members")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("查询租户成员")]
[ProducesResponseType<CatalogList<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminMemberItem>>> GetMembers(
@@ -159,6 +172,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("members")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("新增或更新租户成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> UpsertMember(
@@ -169,6 +183,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("members/disable")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("停用租户成员并撤销会话")]
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> DisableMember(
@@ -179,6 +194,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("audit-logs")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("查询租户审计日志")]
[ProducesResponseType<CatalogList<TenantAdminAuditLogItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminAuditLogItem>>> GetAuditLogs(
@@ -188,45 +204,8 @@ public sealed class TenantAdminDirectController(
return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpGet("permissions")]
[EndpointSummary("查询租户后台权限矩阵")]
[ProducesResponseType<TenantAdminPermissionMatrix>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminPermissionMatrix>> GetPermissions(CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetPermissionMatrixAsync(ResolveActor(), cancellationToken));
}
[HttpGet("role-templates")]
[EndpointSummary("查询租户角色模板")]
[ProducesResponseType<CatalogList<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminRoleTemplateItem>>> GetRoleTemplates(
[FromQuery] TenantAdminRoleTemplateQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetRoleTemplatesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("role-templates")]
[EndpointSummary("新增或更新租户角色模板")]
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> UpsertRoleTemplate(
UpsertTenantAdminRoleTemplateDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertRoleTemplateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("role-templates/disable")]
[EndpointSummary("停用租户角色模板")]
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> DisableRoleTemplate(
DisableTenantAdminRoleTemplateDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.DisableRoleTemplateAsync(ResolveActor(), request.RoleTemplateId, cancellationToken));
}
[HttpPut("branding")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("更新租户品牌信息")]
[ProducesResponseType<ContentManagementResult<TenantBrandingItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantBrandingItem>>> UpsertBranding(
@@ -237,6 +216,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("settings")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("更新租户公开设置与功能开关")]
[ProducesResponseType<ContentManagementResult<TenantSettingsItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantSettingsItem>>> UpsertSettings(
@@ -247,6 +227,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("theme-templates")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询可用租户主题模板")]
[ProducesResponseType<CatalogList<TenantThemeTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantThemeTemplateItem>>> GetThemeTemplates(CancellationToken cancellationToken)
@@ -255,6 +236,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("theme")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询租户当前主题与草稿")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> GetTheme(CancellationToken cancellationToken)
@@ -263,6 +245,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("theme/preview")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("生成租户主题草稿")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PreviewTheme(
@@ -273,6 +256,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("theme/publish")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("发布租户主题")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PublishTheme(
@@ -283,6 +267,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("domains")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询租户域名")]
[ProducesResponseType<CatalogList<TenantDomainItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantDomainItem>>> GetDomains(CancellationToken cancellationToken)
@@ -291,6 +276,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("domains")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("添加租户域名")]
[ProducesResponseType<ContentManagementResult<TenantDomainItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantDomainItem>>> CreateDomain(
@@ -301,6 +287,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("auth-providers")]
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
[EndpointSummary("查询租户登录 Provider 公开配置")]
[ProducesResponseType<CatalogList<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantIdentityProviderItem>>> GetAuthProviders(CancellationToken cancellationToken)
@@ -309,6 +296,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("auth-providers")]
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
[EndpointSummary("新增或更新租户登录 Provider")]
[ProducesResponseType<ContentManagementResult<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantIdentityProviderItem>>> UpsertAuthProvider(
@@ -319,6 +307,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("badges")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户勋章")]
[ProducesResponseType<CatalogList<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminBadgeItem>>> GetBadges(
@@ -329,6 +318,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("badges")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户勋章")]
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeItem>>> UpsertBadge(
@@ -339,6 +329,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("badge-grants")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询勋章发放记录")]
[ProducesResponseType<CatalogList<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminBadgeGrantItem>>> GetBadgeGrants(
@@ -349,6 +340,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("badge-grants")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("向租户成员发放勋章")]
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeGrantItem>>> GrantBadge(
@@ -359,6 +351,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("notifications")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询用户站内通知")]
[ProducesResponseType<CatalogList<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminNotificationItem>>> GetNotifications(
@@ -369,6 +362,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("notifications")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新用户站内通知")]
[ProducesResponseType<ContentManagementResult<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminNotificationItem>>> UpsertNotification(
@@ -379,6 +373,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("feedbacks")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询用户反馈")]
[ProducesResponseType<CatalogList<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminFeedbackItem>>> GetFeedbacks(
@@ -389,6 +384,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("feedbacks/status")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("处理用户反馈")]
[ProducesResponseType<ContentManagementResult<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminFeedbackItem>>> UpdateFeedback(
@@ -400,17 +396,13 @@ public sealed class TenantAdminDirectController(
private TenantAdminActor ResolveActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
try
{
throw new TenantAdminDirectException("Tenant admin actor was not resolved.", "tenant_admin_access_denied");
return TenantAdminActor.FromResolvedIdentity(currentTenant.TenantId, currentUser.UserId);
}
catch (InvalidOperationException exception)
{
throw new TenantAdminDirectException(exception.Message, "tenant_admin_access_denied");
}
var role = Enum.TryParse<TenantRole>(
currentUser.TenantRole?.Replace("_", string.Empty, StringComparison.Ordinal),
ignoreCase: true,
out var parsedRole)
? parsedRole
: TenantRole.TenantAdmin;
return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value, role);
}
}

View File

@@ -8,7 +8,7 @@ using Tiku.Domain.Commerce;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
[Produces("application/json")]
[Route("api/tenant-commerce")]
public sealed class TenantCommerceController(
@@ -17,6 +17,7 @@ public sealed class TenantCommerceController(
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("payment-accounts")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询租户支付账号")]
[ProducesResponseType<IReadOnlyCollection<TenantPaymentProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyCollection<TenantPaymentProviderItem>>> PaymentAccounts(
@@ -30,6 +31,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("payment-accounts")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新租户支付账号")]
[ProducesResponseType<TenantPaymentProviderItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPaymentProviderItem>> UpsertPaymentAccount(
@@ -43,6 +45,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("secrets")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("写入或轮换租户密钥")]
[ProducesResponseType<TenantSecretItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantSecretItem>> UpsertSecret(
@@ -82,6 +85,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("code-batches")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("创建兑换码批次")]
[ProducesResponseType<CodeBatchItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<CodeBatchItem>> CreateCodeBatch(
@@ -95,6 +99,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("activation-codes")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询兑换码")]
[ProducesResponseType<ActivationCodeList>(StatusCodes.Status200OK)]
public async Task<ActionResult<ActivationCodeList>> ActivationCodes(
@@ -108,6 +113,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("activation-codes/redeem")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("后台核销兑换码")]
[ProducesResponseType<ActivationCodeItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<ActivationCodeItem>> RedeemActivationCode(
@@ -121,6 +127,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-activity-tasks")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分活动任务")]
[ProducesResponseType<TenantPointTaskList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointTaskList>> PointTasks(
@@ -134,6 +141,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("point-activity-tasks")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新积分活动任务")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpsertPointTask(
@@ -147,6 +155,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-activity-claims")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分任务领取记录")]
[ProducesResponseType<TenantPointClaimList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointClaimList>> PointClaims(
@@ -160,6 +169,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-exchange-items")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分兑换项")]
[ProducesResponseType<TenantPointExchangeItemList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointExchangeItemList>> PointExchangeItems(
@@ -173,6 +183,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("point-exchange-items")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新积分兑换项")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpsertPointExchangeItem(
@@ -186,6 +197,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-exchange-orders")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分兑换订单")]
[ProducesResponseType<TenantPointExchangeOrderList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointExchangeOrderList>> PointExchangeOrders(
@@ -199,6 +211,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("point-exchange-orders/status")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("更新积分兑换订单状态")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpdatePointExchangeOrderStatus(
@@ -212,6 +225,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("coupons")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询租户优惠券")]
[ProducesResponseType<TenantCouponList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantCouponList>> Coupons(
@@ -225,6 +239,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("coupons")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新租户优惠券")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpsertCoupon(
@@ -238,6 +253,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("coupons/redemptions")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询优惠券领取和核销记录")]
[ProducesResponseType<TenantCouponRedemptionList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantCouponRedemptionList>> CouponRedemptions(
@@ -251,6 +267,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("coupons/report")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询优惠券基础报表")]
[ProducesResponseType<TenantCouponReport>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantCouponReport>> CouponReport(
@@ -316,6 +333,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("reconciliation/batches")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询对账批次")]
[ProducesResponseType<TenantReconciliationBatchList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantReconciliationBatchList>> ReconciliationBatches(
@@ -329,6 +347,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("reconciliation/batches")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("创建对账批次")]
[ProducesResponseType<CommerceReconciliationBatch>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceReconciliationBatch>> CreateReconciliationBatch(
@@ -342,6 +361,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("reconciliation/issues")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询对账异常")]
[ProducesResponseType<TenantReconciliationIssueList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantReconciliationIssueList>> ReconciliationIssues(
@@ -355,6 +375,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("reconciliation/issues/status")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("更新对账异常状态")]
[ProducesResponseType<CommerceReconciliationIssue>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceReconciliationIssue>> UpdateReconciliationIssue(

View File

@@ -9,7 +9,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantContentManage)]
[Produces("application/json")]
[Route("api/tenant-content")]
public sealed class TenantContentController(

View File

@@ -11,7 +11,7 @@ using Tiku.Domain.Content;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantContentManage)]
[Produces("application/json")]
[Route("api/tenant-content")]
public sealed class TenantContentDirectController(
@@ -20,6 +20,7 @@ public sealed class TenantContentDirectController(
ITenantContext currentTenant) : ControllerBase
{
[HttpPost("questions")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("创建题目及首个版本")]
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> CreateQuestion(
@@ -30,6 +31,7 @@ public sealed class TenantContentDirectController(
}
[HttpPatch("questions")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("更新题目并可选择创建新版本")]
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
@@ -60,6 +62,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("vocabulary-words")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询管理侧词汇")]
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
@@ -70,6 +73,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("vocabulary-words")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新词汇")]
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
@@ -100,6 +104,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("handbook-chapters")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询管理侧知识手册章节")]
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
@@ -110,6 +115,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("handbook-chapters")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新知识手册章节")]
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
@@ -120,6 +126,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("handbook-entries")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询管理侧知识手册条目")]
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
@@ -130,6 +137,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("handbook-entries")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新知识手册条目")]
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
@@ -240,6 +248,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("videos")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询租户视频解析")]
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
@@ -250,6 +259,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("videos")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新视频解析")]
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
@@ -260,6 +270,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("question-videos")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("绑定题目与解析视频")]
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
@@ -270,6 +281,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("operations/{kind}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询运营内容")]
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
@@ -281,6 +293,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("operations/{kind}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新运营内容")]
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
@@ -292,6 +305,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("imports/preview/{importType}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("预览内容导入数据")]
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
@@ -303,6 +317,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("imports/{importType}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("执行同步内容导入")]
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
@@ -314,6 +329,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("imports/issues")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询内容导入问题明细")]
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
@@ -324,6 +340,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("imports/post-check")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("执行内容导入后完整性检查")]
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
@@ -334,6 +351,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("imports/post-check")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询内容导入后检查状态")]
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(

View File

@@ -7,7 +7,7 @@ using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[Produces("application/json")]
[Route("api/tenant-admin/frontend-config")]
public sealed class TenantFrontendConfigController(

View File

@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
@@ -39,8 +38,7 @@ public sealed class TenantsController(
tenant.Name,
tenant.Slug,
tenant.Status,
membership.Role,
membership.Permissions))
membership.Role))
.SingleOrDefaultAsync(cancellationToken);
if (result is null)
@@ -60,11 +58,9 @@ public sealed class TenantsController(
/// <param name="TenantSlug">租户编码。</param>
/// <param name="Status">租户状态。</param>
/// <param name="Role">当前用户在租户内的角色。</param>
/// <param name="Permissions">当前用户在租户内的权限扩展。</param>
public sealed record CurrentTenantResponse(
Guid TenantId,
string TenantName,
string TenantSlug,
TenantStatus Status,
TenantRole Role,
JsonElement Permissions);
TenantRole Role);

View File

@@ -26,7 +26,7 @@ public static class SerilogRequestLogging
SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId);
SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId);
SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId);
SetClaim(diagnosticContext, "TenantRole", user, TikuClaimTypes.TenantRole);
SetClaim(diagnosticContext, "AuthRealm", user, TikuClaimTypes.Realm);
};
}

View File

@@ -0,0 +1,106 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.RateLimiting;
using Tiku.Api.Options;
namespace Tiku.Api.Middleware;
public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
var policy = context.GetEndpoint()?
.Metadata
.GetMetadata<EnableRateLimitingAttribute>()?
.PolicyName;
var propertyName = policy switch
{
AuthRateLimitPolicies.Password => "identifier",
AuthRateLimitPolicies.Sms => "phone",
AuthRateLimitPolicies.Mfa => "challengeToken",
_ => null
};
if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
{
await CaptureAccountHashAsync(context, propertyName);
}
await next(context);
}
private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName)
{
context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384);
try
{
using var document = await JsonDocument.ParseAsync(
context.Request.Body,
cancellationToken: context.RequestAborted);
var captured = TryGetStringProperty(document.RootElement, propertyName) ??
(propertyName == "identifier" ? TryGetStringProperty(document.RootElement, "phone") : null);
if (captured is { } value &&
!string.IsNullOrWhiteSpace(value))
{
context.Items[AuthRateLimitPartitionKey.AccountHashItemKey] = Hash(value.Trim());
}
}
catch (JsonException)
{
// MVC will produce the canonical malformed JSON response.
}
catch (IOException)
{
// Oversized or unreadable bodies share the IP-only fallback partition.
}
finally
{
if (context.Request.Body.CanSeek)
{
context.Request.Body.Position = 0;
}
}
}
private static string? TryGetStringProperty(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind == JsonValueKind.String)
{
return property.Value.GetString();
}
}
return null;
}
private static string Hash(string value)
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))
.ToLowerInvariant();
}
}
public static class AuthRateLimitPartitionKey
{
internal const string AccountHashItemKey = "tiku.auth_rate_limit.account_hash";
public static string Resolve(HttpContext context, string policyName)
{
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
var accountHash = context.Items.TryGetValue(AccountHashItemKey, out var value) &&
value is string hash &&
!string.IsNullOrWhiteSpace(hash)
? hash
: "unknown-account";
return $"{policyName}:{ipAddress}:{accountHash}";
}
}

View File

@@ -16,6 +16,7 @@ using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Application.TenantAdmin;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Backoffice;
namespace Tiku.Api.Middleware;
@@ -224,6 +225,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is BackofficeException backofficeException)
{
await WriteProblemAsync(
context,
backofficeException.Message,
BackofficeStatusCode(backofficeException.Code),
backofficeException.Code);
return;
}
if (exception is CommerceException commerceException)
{
await WriteProblemAsync(
@@ -404,6 +415,7 @@ public sealed class ExceptionHandlingMiddleware(
"entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or
"import_type_invalid" => StatusCodes.Status404NotFound,
"tenant_content_access_denied" => StatusCodes.Status403Forbidden,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
@@ -425,8 +437,8 @@ public sealed class ExceptionHandlingMiddleware(
{
"tenant_admin_access_denied" => StatusCodes.Status403Forbidden,
"class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound,
"tenant_member_not_found" => StatusCodes.Status400BadRequest,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status400BadRequest,
"tenant_member_not_found" => StatusCodes.Status404NotFound,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
@@ -442,6 +454,17 @@ public sealed class ExceptionHandlingMiddleware(
"payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable,
"order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or
"coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
private static int BackofficeStatusCode(string code)
{
return code switch
{
"platform_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace Tiku.Api.Options;
public sealed class AuthRateLimitOptions
{
public const string SectionName = "RateLimiting:Authentication";
[Range(1, 100)]
public int PasswordPermitLimit { get; set; } = 5;
[Range(1, 86_400)]
public int PasswordWindowSeconds { get; set; } = 900;
[Range(1, 100)]
public int SmsPermitLimit { get; set; } = 5;
[Range(1, 86_400)]
public int SmsWindowSeconds { get; set; } = 300;
[Range(1, 100)]
public int MfaPermitLimit { get; set; } = 5;
[Range(1, 86_400)]
public int MfaWindowSeconds { get; set; } = 300;
}
public static class AuthRateLimitPolicies
{
public const string Password = "auth-password";
public const string Sms = "auth-sms";
public const string Mfa = "auth-mfa";
}

View File

@@ -5,8 +5,6 @@ namespace Tiku.Api.Options;
public static class OptionsValidation
{
public const string DevelopmentSigningKey = "development-only-tiku-signing-key-change-before-production";
public static string ResolveDatabaseConnectionString(
IConfiguration configuration,
bool isDevelopment)
@@ -30,11 +28,7 @@ public static class OptionsValidation
public static bool BeValidJwtOptions(JwtOptions options, bool isProduction)
{
return !isProduction ||
!string.Equals(
options.SigningKey,
DevelopmentSigningKey,
StringComparison.Ordinal);
return JwtOptions.BeValid(options, isProduction);
}
public static bool BeValidCorsOptions(CorsOptions options)

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
@@ -8,7 +9,6 @@ using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
using Serilog.Events;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Tiku.Api.Logging;
@@ -17,11 +17,13 @@ using Tiku.Api.OpenApi;
using Tiku.Api.Options;
using Tiku.Api.Security;
using Tiku.Application;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
using Tiku.Infrastructure.Storage;
Log.Logger = new LoggerConfiguration()
@@ -116,11 +118,18 @@ try
var rateLimitOptions = builder.Configuration
.GetSection(ApiRateLimitOptions.SectionName)
.Get<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
if (rateLimitOptions.Enabled)
builder.Services.AddOptions<AuthRateLimitOptions>()
.Bind(builder.Configuration.GetSection(AuthRateLimitOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
var authRateLimitOptions = builder.Configuration
.GetSection(AuthRateLimitOptions.SectionName)
.Get<AuthRateLimitOptions>() ?? new AuthRateLimitOptions();
builder.Services.AddRateLimiter(options =>
{
builder.Services.AddRateLimiter(options =>
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
if (rateLimitOptions.Enabled)
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var partitionKey =
@@ -139,33 +148,113 @@ try
Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds)
});
});
options.OnRejected = async (context, cancellationToken) =>
}
options.AddPolicy(
AuthRateLimitPolicies.Password,
httpContext => RateLimitPartition.GetFixedWindowLimiter(
AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Password),
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = authRateLimitOptions.PasswordPermitLimit,
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(authRateLimitOptions.PasswordWindowSeconds)
}));
options.AddPolicy(
AuthRateLimitPolicies.Sms,
httpContext => RateLimitPartition.GetFixedWindowLimiter(
AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Sms),
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = authRateLimitOptions.SmsPermitLimit,
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(authRateLimitOptions.SmsWindowSeconds)
}));
options.AddPolicy(
AuthRateLimitPolicies.Mfa,
httpContext => RateLimitPartition.GetFixedWindowLimiter(
AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Mfa),
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = authRateLimitOptions.MfaPermitLimit,
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(authRateLimitOptions.MfaWindowSeconds)
}));
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
var problem = new ProblemDetails
{
Title = "Too many requests.",
Status = StatusCodes.Status429TooManyRequests,
Instance = context.HttpContext.Request.Path
};
problem.Extensions["code"] = "rate_limited";
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
var problem = new ProblemDetails
{
Title = "Too many requests.",
Status = StatusCodes.Status429TooManyRequests,
Instance = context.HttpContext.Request.Path
};
});
}
problem.Extensions["code"] = "rate_limited";
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
};
});
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
builder.Configuration,
builder.Environment.IsDevelopment());
builder.Services.AddInfrastructure(connectionString);
var requireProtectedDataProtectionKeys = !builder.Environment.IsDevelopment();
builder.Services.AddOptions<DataProtectionKeyRingOptions>()
.Bind(builder.Configuration.GetSection(DataProtectionKeyRingOptions.SectionName))
.PostConfigure(options =>
{
options.ApplicationName =
builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? options.ApplicationName;
options.CertificatePath =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? options.CertificatePath;
options.CertificatePassword =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? options.CertificatePassword;
})
.Validate(
options => DataProtectionKeyRingOptions.BeValid(options, requireProtectedDataProtectionKeys),
"Data Protection requires an application name and, outside Development, an X509 certificate path.")
.ValidateOnStart();
var dataProtectionOptions = builder.Configuration
.GetSection(DataProtectionKeyRingOptions.SectionName)
.Get<DataProtectionKeyRingOptions>() ?? new DataProtectionKeyRingOptions();
dataProtectionOptions.ApplicationName =
builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? dataProtectionOptions.ApplicationName;
dataProtectionOptions.CertificatePath =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? dataProtectionOptions.CertificatePath;
dataProtectionOptions.CertificatePassword =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? dataProtectionOptions.CertificatePassword;
if (!DataProtectionKeyRingOptions.BeValid(dataProtectionOptions, requireProtectedDataProtectionKeys))
{
throw new InvalidOperationException(
"Data Protection requires an application name and, outside Development, an X509 certificate path.");
}
var dataProtection = builder.Services
.AddDataProtection()
.SetApplicationName(dataProtectionOptions.ApplicationName.Trim())
.PersistKeysToDbContext<TikuDbContext>();
var dataProtectionCertificate = dataProtectionOptions.LoadCertificate(requireProtectedDataProtectionKeys);
if (dataProtectionCertificate is not null)
{
dataProtection.ProtectKeysWithCertificate(dataProtectionCertificate);
}
builder.Services.Configure<ObjectStorageOptions>(
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
builder.Services.Configure<AliyunOssOptions>(
@@ -215,6 +304,18 @@ try
"Production tenant secret encryption cannot use the development master key.")
.ValidateOnStart();
builder.Services.AddOptions<SmsSecurityOptions>()
.Bind(builder.Configuration.GetSection(SmsSecurityOptions.SectionName))
.PostConfigure(options =>
{
options.CodePepper = builder.Configuration["TIKU_SMS_CODE_PEPPER"] ?? options.CodePepper;
})
.Validate(
SmsSecurityOptions.BeValid,
"SMS security requires a pepper of at least 32 characters, exactly five verification attempts, " +
"and positive tenant, phone, IP, and device rate limits.")
.ValidateOnStart();
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Security:Jwt"))
.ValidateDataAnnotations()
@@ -230,6 +331,7 @@ try
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
@@ -237,57 +339,93 @@ try
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
RequireSignedTokens = true,
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
RequireExpirationTime = true,
ClockSkew = TimeSpan.FromMinutes(1),
NameClaimType = TikuClaimTypes.UserId
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value;
if (!Guid.TryParse(tenantIdValue, out var tenantId))
var principal = context.Principal;
if (!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) ||
!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.SessionId)?.Value, out var sessionId) ||
string.IsNullOrWhiteSpace(principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti)?.Value) ||
!long.TryParse(
principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Iat)?.Value,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out _))
{
context.Fail("Missing tenant claim.");
context.Fail("Missing or invalid subject/session/jti/iat claim.");
return;
}
var realmValue = principal.FindFirst(TikuClaimTypes.Realm)?.Value;
var realm = string.Equals(realmValue, "tenant", StringComparison.Ordinal)
? Tiku.Domain.Tenancy.AuthRealm.Tenant
: string.Equals(realmValue, "platform", StringComparison.Ordinal)
? Tiku.Domain.Tenancy.AuthRealm.Platform
: (Tiku.Domain.Tenancy.AuthRealm?)null;
var tenantIdValue = principal.FindFirst(TikuClaimTypes.TenantId)?.Value;
var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId)
? parsedTenantId
: (Guid?)null;
if (realm is null || (realm == Tiku.Domain.Tenancy.AuthRealm.Tenant) != tenantId.HasValue)
{
context.Fail("Token scope and tenant claims are inconsistent.");
return;
}
var resolutionOptions = context.HttpContext.RequestServices
.GetRequiredService<Microsoft.Extensions.Options.IOptions<TenantResolutionOptions>>().Value;
var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.');
var isPlatformHost = resolutionOptions.PlatformHosts.Any(host =>
string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase));
var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService<ITenantContext>();
var tenantInitializer = context.HttpContext.RequestServices
.GetRequiredService<ITenantContextInitializer>();
try
if (realm == Tiku.Domain.Tenancy.AuthRealm.Platform)
{
tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt);
if (!isPlatformHost || resolvedTenantContext.IsResolved)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Platform tokens are only valid on a platform host.");
return;
}
}
catch (TenantContextConflictException)
else
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Authenticated tenant does not match the request host.");
return;
if (isPlatformHost && !resolvedTenantContext.IsResolved)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Tenant tokens on a platform host require a matching tenant code.");
return;
}
try
{
tenantInitializer.Initialize(tenantId!.Value, 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)
var sessionStore = context.HttpContext.RequestServices.GetRequiredService<IAuthSessionStore>();
var session = await sessionStore.ValidateAccessSessionAsync(
sessionId, userId, realm.Value, tenantId, context.HttpContext.RequestAborted);
var tokenMfaSatisfied = principal.FindAll(TikuClaimTypes.Mfa)
.Any(claim => string.Equals(claim.Value, "mfa", StringComparison.Ordinal));
if (session is null || session.MfaSatisfied != tokenMfaSatisfied)
{
return;
}
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
if (!Guid.TryParse(sessionIdValue, out var sessionId))
{
context.Fail("Missing session claim.");
return;
}
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
var now = DateTimeOffset.UtcNow;
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
session =>
session.Id == sessionId &&
session.RevokedAt == null &&
session.ExpiresAt > now);
if (!isSessionActive)
{
context.Fail("Session has been revoked or expired.");
context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid.");
}
},
OnChallenge = async context =>
@@ -307,32 +445,44 @@ try
};
});
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IJwtKeyRing>((options, keyRing) =>
{
options.TokenValidationParameters.IssuerSigningKeys = keyRing.ValidationKeys;
options.TokenValidationParameters.TryAllIssuerSigningKeys = false;
options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) =>
string.IsNullOrWhiteSpace(kid)
? []
: keyRing.ValidationKeys.Where(key =>
string.Equals(key.KeyId, kid, StringComparison.Ordinal));
});
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.AddPolicy(
TikuPolicies.AuthenticatedUser,
policy => policy.RequireAuthenticatedUser());
options.AddPolicy(
TikuPolicies.CurrentTenantMember,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
options.AddPolicy(
TikuPolicies.TenantAdmin,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
policy => policy.RequireAuthenticatedUser());
});
builder.Services.AddTikuRbacAuthorization();
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationMiddlewareResultHandler,
AuditingAuthorizationMiddlewareResultHandler>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapOpenApi().AllowAnonymous();
app.MapScalarApiReference(options => options
.WithTitle("TIKU Backend API")
.AddPreferredSecuritySchemes("BearerAuth")
.EnablePersistentAuthentication());
.EnablePersistentAuthentication())
.AllowAnonymous();
}
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
@@ -343,10 +493,8 @@ try
app.UseCors(CorsOptions.PolicyName);
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthentication();
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter();
}
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
app.UseRateLimiter();
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();

View File

@@ -0,0 +1,288 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
namespace Tiku.Api.Security;
public sealed record CurrentTenantMemberRequirement : IAuthorizationRequirement;
public sealed record CurrentPlatformAccessRequirement : IAuthorizationRequirement;
public sealed record TenantPermissionRequirement : IAuthorizationRequirement
{
public TenantPermissionRequirement(string permissionCode)
{
BackendPermissions.EnsureTenant(permissionCode);
PermissionCode = permissionCode;
}
public string PermissionCode { get; }
}
public sealed record PlatformPermissionRequirement : IAuthorizationRequirement
{
public PlatformPermissionRequirement(string permissionCode)
{
BackendPermissions.EnsurePlatform(permissionCode);
PermissionCode = permissionCode;
}
public string PermissionCode { get; }
}
public sealed record MfaRequirement : IAuthorizationRequirement;
public sealed record AllDataScopeRequirement : IAuthorizationRequirement;
public sealed record TenantResourceAccessRequirement : IAuthorizationRequirement;
public sealed record TenantResourceAuthorizationResource(
Guid TenantId,
Guid? OwnerUserId = null,
Guid? RegionId = null,
Guid? ClassId = null);
internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<CurrentTenantMemberRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CurrentTenantMemberRequirement requirement)
{
if (!IsTenantRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.IsCurrentTenantMember &&
access.TenantId is { } tenantId &&
FindTenantId(context.User) == tenantId)
{
context.Succeed(requirement);
}
}
internal static bool IsTenantRealm(ClaimsPrincipal principal) =>
string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "tenant", StringComparison.Ordinal);
internal static bool IsPlatformRealm(ClaimsPrincipal principal) =>
string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "platform", StringComparison.Ordinal) &&
principal.FindFirst(TikuClaimTypes.TenantId) is null;
private static Guid? FindTenantId(ClaimsPrincipal principal) =>
Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null;
}
internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<TenantPermissionRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
TenantPermissionRequirement requirement)
{
if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.HasTenantPermission(requirement.PermissionCode))
{
context.Succeed(requirement);
}
}
}
internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<CurrentPlatformAccessRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CurrentPlatformAccessRequirement requirement)
{
if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.IsUserActive && access.PlatformPermissions.Count > 0)
{
context.Succeed(requirement);
}
}
}
internal sealed class TenantResourceAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<TenantResourceAccessRequirement, TenantResourceAuthorizationResource>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
TenantResourceAccessRequirement requirement,
TenantResourceAuthorizationResource resource)
{
if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.UserId is { } userId &&
access.IsCurrentTenantMember &&
access.TenantId == resource.TenantId &&
access.DataScope.AllowsResource(userId, resource.OwnerUserId, resource.RegionId, resource.ClassId))
{
context.Succeed(requirement);
}
}
}
internal sealed class PlatformPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<PlatformPermissionRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PlatformPermissionRequirement requirement)
{
if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.HasPlatformPermission(requirement.PermissionCode))
{
context.Succeed(requirement);
}
}
}
internal sealed class MfaAuthorizationHandler : AuthorizationHandler<MfaRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MfaRequirement requirement)
{
if (context.User.FindAll(TikuClaimTypes.Mfa).Any(claim =>
string.Equals(claim.Value, "mfa", StringComparison.OrdinalIgnoreCase) ||
string.Equals(claim.Value, "totp", StringComparison.OrdinalIgnoreCase) ||
string.Equals(claim.Value, bool.TrueString, StringComparison.OrdinalIgnoreCase)))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
internal sealed class AllDataScopeAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<AllDataScopeRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AllDataScopeRequirement requirement)
{
var access = await accessContext.GetAsync();
if (access.IsCurrentTenantMember && access.DataScope.Mode == DataScopeMode.All)
{
context.Succeed(requirement);
}
}
}
public static class AccessAuthorizationServiceCollectionExtensions
{
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, PlatformPermissionAuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, MfaAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, AllDataScopeAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantResourceAccessAuthorizationHandler>();
services.AddAuthorization(options =>
{
options.AddPolicy(
TikuPolicies.CurrentTenantMember,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new CurrentTenantMemberRequirement()));
options.AddPolicy(
TikuPolicies.Mfa,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new MfaRequirement()));
options.AddPolicy(
TikuPolicies.TenantBackofficeBootstrap,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new MfaRequirement()));
options.AddPolicy(
TikuPolicies.PlatformBackofficeBootstrap,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentPlatformAccessRequirement(),
new MfaRequirement()));
// Temporary compatibility for controllers that have not yet been
// split into their module-specific permission policy. This must
// remain database-backed; an authenticated-only alias would reopen
// every legacy tenant administration endpoint to ordinary users.
options.AddPolicy(
TikuPolicies.TenantAdmin,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantRoleManage),
new MfaRequirement()));
options.AddPolicy(
TikuPolicies.TenantContentManageAllScope,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantContentManage),
new AllDataScopeRequirement(),
new MfaRequirement()));
options.AddPolicy(
TikuPolicies.TenantCommerceOperateAllScope,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantCommerceOperate),
new AllDataScopeRequirement(),
new MfaRequirement()));
foreach (var permissionCode in BackendPermissions.Tenant)
{
options.AddPolicy(
permissionCode,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(permissionCode),
new MfaRequirement()));
}
foreach (var permissionCode in BackendPermissions.Platform)
{
options.AddPolicy(
permissionCode,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new PlatformPermissionRequirement(permissionCode),
new MfaRequirement()));
}
});
return services;
}
}

View File

@@ -0,0 +1,59 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Tiku.Application.Security;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Api.Security;
internal sealed class AuditingAuthorizationMiddlewareResultHandler(
IServiceScopeFactory scopeFactory,
ILogger<AuditingAuthorizationMiddlewareResultHandler> logger) : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler fallback = new();
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId)
? tenantId
: null,
ActorUserId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId)
? userId
: null,
Action = "authorization.access_denied",
TargetType = "http_endpoint",
TargetId = context.Request.Path,
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
UserAgent = context.Request.Headers.UserAgent.ToString(),
Details = JsonSerializer.SerializeToElement(new
{
Method = context.Request.Method,
Path = context.Request.Path.Value,
Realm = context.User.FindFirst(TikuClaimTypes.Realm)?.Value,
Failure = authorizeResult.AuthorizationFailure?.FailureReasons.Select(reason => reason.Message).ToArray()
})
});
await dbContext.SaveChangesAsync(context.RequestAborted);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to persist authorization denial audit event.");
}
}
await fallback.HandleAsync(next, context, policy, authorizeResult);
}
}

View File

@@ -1,31 +0,0 @@
using System.Security.Claims;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using ZLinq;
namespace Tiku.Api.Security;
internal static class TenantRoleAuthorization
{
private static readonly HashSet<string> AdminRoles = new(StringComparer.Ordinal)
{
nameof(TenantRole.PlatformAdmin),
nameof(TenantRole.TenantOwner),
nameof(TenantRole.TenantAdmin)
};
public static bool IsTenantMember(ClaimsPrincipal principal)
{
return principal.Identity?.IsAuthenticated == true &&
principal.HasClaim(claim => claim.Type == TikuClaimTypes.TenantId);
}
public static bool IsTenantAdmin(ClaimsPrincipal principal)
{
return IsTenantMember(principal) &&
principal.Claims.AsValueEnumerable()
.Where(claim => claim.Type == TikuClaimTypes.TenantRole)
.Select(claim => claim.Value)
.Any(role => AdminRoles.Contains(role));
}
}

View File

@@ -24,9 +24,14 @@
"WindowSeconds": 60,
"QueueLimit": 0
},
"Authentication": {
"Sms": {
"CodePepper": "development-only-sms-code-pepper-change-before-production"
}
},
"Security": {
"Jwt": {
"SigningKey": "development-only-tiku-signing-key-change-before-production"
"KeyId": "development-ephemeral"
},
"TenantSecrets": {
"KeyId": "development-v1",

View File

@@ -47,7 +47,25 @@
"Enabled": true,
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 0
"QueueLimit": 0,
"Authentication": {
"PasswordPermitLimit": 5,
"PasswordWindowSeconds": 900,
"SmsPermitLimit": 5,
"SmsWindowSeconds": 300,
"MfaPermitLimit": 5,
"MfaWindowSeconds": 300
}
},
"Authentication": {
"Sms": {
"CodePepper": "",
"MaxVerificationAttempts": 5,
"TenantRequestsPerHour": 100,
"PhoneRequestsPerHour": 5,
"IpRequestsPerHour": 20,
"DeviceRequestsPerHour": 10
}
},
"Storage": {
"DefaultProvider": "aliyun_oss",
@@ -84,14 +102,20 @@
"Jwt": {
"Issuer": "tiku-backend",
"Audience": "tiku-api",
"SigningKey": "",
"AccessTokenMinutes": 30,
"RefreshTokenDays": 30,
"ValidateSessions": true
"KeyId": "",
"PrivateKeyPem": "",
"PublicKeys": {},
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"TenantSecrets": {
"KeyId": "",
"MasterKey": ""
},
"DataProtection": {
"ApplicationName": "Tiku.Api",
"CertificatePath": "",
"CertificatePassword": ""
}
},
"AllowedHosts": "*"