forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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/unionid,upsert 用户身份并创建应用会话。")]
|
||||
[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_key,upsert 用户身份并创建应用会话。")]
|
||||
[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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
106
Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs
Normal file
106
Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
33
Tiku.Api/Options/AuthRateLimitOptions.cs
Normal file
33
Tiku.Api/Options/AuthRateLimitOptions.cs
Normal 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";
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
288
Tiku.Api/Security/AccessAuthorizationRequirements.cs
Normal file
288
Tiku.Api/Security/AccessAuthorizationRequirements.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "*"
|
||||
|
||||
Reference in New Issue
Block a user