feat(security): add distributed authorization foundation

This commit is contained in:
2026-07-29 10:40:10 +08:00
parent c7f9a4e3c9
commit df88fa19cb
76 changed files with 22020 additions and 88 deletions

View File

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Tiku.Api.OpenApi;
using Tiku.Api.Security;
namespace Tiku.Api.Configuration;
@@ -7,7 +8,8 @@ internal static class ApiPresentationExtensions
{
internal static IServiceCollection AddApiPresentation(this IServiceCollection services)
{
services.AddControllers()
services.AddControllers(options =>
options.Conventions.Add(new EndpointAuthorizationMetadataConvention()))
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());

View File

@@ -29,6 +29,7 @@ public static class ApplicationBuilderExtensions
app.UseRouting();
app.UseCors(CorsOptions.PolicyName);
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseMiddleware<BrowserCsrfMiddleware>();
app.UseAuthentication();
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
app.UseRateLimiter();

View File

@@ -29,6 +29,15 @@ internal static class AuthenticationExtensions
var jwtOptions = configuration
.GetSection("Security:Jwt")
.Get<JwtOptions>() ?? new JwtOptions();
services.AddOptions<BrowserAuthOptions>()
.Bind(configuration.GetSection(BrowserAuthOptions.SectionName))
.Validate(
options => options.AllowedOrigins.All(origin =>
Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) &&
string.IsNullOrEmpty(uri.PathAndQuery.Trim('/'))),
"BrowserAuth AllowedOrigins must contain only HTTP(S) origins without paths.")
.ValidateOnStart();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -84,11 +93,41 @@ internal static class AuthenticationExtensions
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (string.IsNullOrWhiteSpace(context.Request.Headers.Authorization) &&
IsSameOriginBrowserRequest(context.Request) &&
context.Request.Cookies.TryGetValue(BrowserAuthOptions.AccessCookie, out var accessToken))
{
context.Token = accessToken;
}
return Task.CompletedTask;
},
OnTokenValidated = ValidateTokenAsync,
OnChallenge = WriteTenantConflictChallengeAsync
};
}
private static bool IsSameOriginBrowserRequest(HttpRequest request)
{
var source = request.Headers.Origin.ToString();
if (string.IsNullOrWhiteSpace(source))
{
source = request.Headers.Referer.ToString();
}
if (Uri.TryCreate(source, UriKind.Absolute, out var uri))
{
return string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(
request.Headers["Sec-Fetch-Site"].ToString(),
"same-origin",
StringComparison.OrdinalIgnoreCase);
}
private static async Task ValidateTokenAsync(TokenValidatedContext context)
{
var principal = context.Principal;

View File

@@ -1,6 +1,8 @@
using Serilog;
using Tiku.Application;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Security;
namespace Tiku.Api.Configuration;
@@ -15,14 +17,51 @@ public static class DependencyInjection
preserveStaticLogger: true);
builder.Services.AddApiPresentation();
builder.Services.AddHealthChecks();
builder.Services.AddApplication();
builder.Services.AddNetworkConfiguration(builder.Configuration);
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
builder.Services.AddApiRateLimiting(builder.Configuration);
var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString(
builder.Configuration,
builder.Environment.IsDevelopment());
builder.Services.AddInfrastructure(connectionString);
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
builder.Services.AddOptions<RedisSecurityConnectionOptions>()
.Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty)
.Validate(
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
"Redis is required in Production.")
.ValidateOnStart();
if (!string.IsNullOrWhiteSpace(redisConnectionString))
{
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
}
else if (builder.Environment.IsProduction())
{
throw new InvalidOperationException(
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
}
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
builder.Services.AddOptions<MessagingOptions>()
.Bind(builder.Configuration.GetSection("RabbitMq"))
.Validate(
options => !builder.Environment.IsProduction() ||
(options.IsConfigured &&
!string.IsNullOrWhiteSpace(options.Username) &&
!string.IsNullOrWhiteSpace(options.Password)),
"Production RabbitMQ requires a valid Host, Username and Password.")
.ValidateOnStart();
builder.Services.AddSingleton(messaging);
if (messaging.IsConfigured)
{
messaging.ConfigureConsumers = false;
builder.Services.AddReliableMessaging(messaging);
}
else if (builder.Environment.IsProduction())
{
throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host.");
}
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);

View File

@@ -9,10 +9,15 @@ internal static class NetworkConfigurationExtensions
{
internal static IServiceCollection AddNetworkConfiguration(
this IServiceCollection services,
IConfiguration configuration)
IConfiguration configuration,
IHostEnvironment environment)
{
services.AddOptions<TenantResolutionOptions>()
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName));
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName))
.Validate(
options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration, environment.IsProduction()),
"Production requires formal platform hosts, non-wildcard AllowedHosts, and trusted proxy addresses.")
.ValidateOnStart();
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =

View File

@@ -28,6 +28,8 @@ public sealed class AuthController(
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("sms/send")]
[EndpointSummary("发送短信验证码")]
[EndpointDescription("发送登录用途短信验证码,并应用租户级短信限流。")]
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
@@ -86,6 +88,7 @@ public sealed class AuthController(
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("login/sms")]
[EndpointSummary("短信验证码登录")]
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
@@ -195,6 +198,8 @@ public sealed class AuthController(
[HttpPost("logout-all")]
[Authorize]
[EndpointSummary("退出全部登录会话")]
[EndpointDescription("撤销当前用户全部 refresh/session会话校验开启时旧 access token 也会被拒绝。")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
{
@@ -210,6 +215,8 @@ public sealed class AuthController(
[AllowAnonymous]
[HttpPost("password/change-required")]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[EndpointSummary("修改首次登录必改密码")]
[EndpointDescription("校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。")]
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
[FromBody] RequiredPasswordChangeDto request,
CancellationToken cancellationToken)

View File

@@ -14,6 +14,7 @@ public sealed class BackgroundJobsController(
ITenantContext tenantContext) : ControllerBase
{
[HttpGet]
[EndpointSummary("查询租户后台任务")]
[ProducesResponseType<IReadOnlyCollection<BackgroundJobItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> List(
[FromQuery] string? jobType,
@@ -25,6 +26,7 @@ public sealed class BackgroundJobsController(
}
[HttpPost]
[EndpointSummary("创建租户后台任务")]
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackgroundJobItem>> Create(
CreateBackgroundJobDto request,

View File

@@ -14,6 +14,8 @@ public sealed class BackofficeController(
{
[HttpGet("tenant/ui-bootstrap")]
[Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
[EndpointSummary("查询租户后台菜单与权限")]
[EndpointDescription("返回当前租户管理员可见的后台菜单、权限和模块启用状态。")]
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeUiBootstrap>> GetTenantUiBootstrap(CancellationToken cancellationToken)
{
@@ -24,6 +26,7 @@ public sealed class BackofficeController(
[HttpGet("tenant/bootstrap")]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[EndpointSummary("查询租户角色管理初始化数据")]
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
{
@@ -32,6 +35,8 @@ public sealed class BackofficeController(
[HttpGet("platform/ui-bootstrap")]
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
[EndpointSummary("查询平台后台菜单与权限")]
[EndpointDescription("返回当前平台管理员可见的后台菜单、权限和模块启用状态。")]
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeUiBootstrap>> GetPlatformUiBootstrap(CancellationToken cancellationToken)
{
@@ -42,6 +47,7 @@ public sealed class BackofficeController(
[HttpPost("tenant/roles")]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[EndpointSummary("创建或更新租户后台角色")]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
UpsertBackofficeRoleDto request,
@@ -52,6 +58,7 @@ public sealed class BackofficeController(
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[EndpointSummary("替换租户后台角色权限绑定")]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
Guid roleId,
@@ -63,6 +70,7 @@ public sealed class BackofficeController(
[HttpPut("tenant/users/{userId:guid}/roles")]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[EndpointSummary("替换租户用户后台角色")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ReplaceTenantUserRoles(
Guid userId,
@@ -75,6 +83,7 @@ public sealed class BackofficeController(
[HttpGet("platform/bootstrap")]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[EndpointSummary("查询平台角色管理初始化数据")]
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
{
@@ -83,6 +92,7 @@ public sealed class BackofficeController(
[HttpPost("platform/roles")]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[EndpointSummary("创建或更新平台后台角色")]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
UpsertBackofficeRoleDto request,
@@ -93,6 +103,7 @@ public sealed class BackofficeController(
[HttpPut("platform/roles/{roleId:guid}/bindings")]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[EndpointSummary("替换平台后台角色权限绑定")]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
Guid roleId,
@@ -104,6 +115,7 @@ public sealed class BackofficeController(
[HttpPut("platform/users/{userId:guid}/roles")]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[EndpointSummary("替换平台用户后台角色")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ReplacePlatformUserRoles(
Guid userId,

View File

@@ -0,0 +1,251 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Content;
namespace Tiku.Api.Controllers;
[ApiController]
[Route("api/browser-auth")]
[Produces("application/json")]
public sealed class BrowserAuthController(
IAuthService authService,
ISmsVerificationService smsVerificationService,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory,
ICurrentUser currentUser,
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
{
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("sms/send")]
public async Task<ActionResult<SmsSendResult>> SendSmsCode(
[FromBody] SendSmsCodeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
if (realm != AuthRealm.Tenant)
{
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
}
var tenantId = await ResolveTenantIdAsync(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,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
request.DeviceId), cancellationToken);
return Accepted(result);
}
[AllowAnonymous]
[HttpPost("login/password")]
public async Task<ActionResult<object>> LoginWithPassword(
[FromBody] PasswordLoginDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
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(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
identifier,
request.Password,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("login/sms")]
public async Task<ActionResult<object>> LoginWithSms(
[FromBody] SmsLoginDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
var result = await authService.LoginWithSmsAsync(new SmsLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Phone,
request.Code,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat")]
public async Task<ActionResult<object>> LoginWithWechatWeb(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat-miniapp")]
public async Task<ActionResult<object>> LoginWithWechatMiniApp(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<object>> Refresh(CancellationToken cancellationToken)
{
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
if (string.IsNullOrWhiteSpace(refreshToken)) return Unauthorized();
var tokens = await authService.RefreshAsync(new RefreshSessionRequest(
refreshToken,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
WriteCookies(tokens);
return Ok(new { status = "authenticated" });
}
[AllowAnonymous]
[HttpPost("logout")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
if (!string.IsNullOrWhiteSpace(refreshToken))
{
await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken);
}
ClearCookies();
return NoContent();
}
[Authorize]
[HttpPost("logout-all")]
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
await authService.LogoutAllAsync(userId, cancellationToken);
ClearCookies();
return NoContent();
}
private object WriteResult(AuthenticationResult result)
{
if (result.User?.Tokens is { } tokens)
{
WriteCookies(tokens);
}
return new
{
status = result.Status.ToString(),
user = result.User is null ? null : new
{
result.User.UserId,
result.User.Phone,
result.User.Email,
result.User.Name,
result.User.Realm,
result.User.Tenant
},
result.ChallengeToken,
result.ChallengeExpiresAt
};
}
private void WriteCookies(AuthTokenPair tokens)
{
Response.Cookies.Append(BrowserAuthOptions.AccessCookie, tokens.AccessToken, new CookieOptions
{
Secure = true,
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = TimeSpan.FromMinutes(15)
});
Response.Cookies.Append(BrowserAuthOptions.RefreshCookie, tokens.RefreshToken, new CookieOptions
{
Secure = true,
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Path = "/api/browser-auth",
MaxAge = TimeSpan.FromDays(30)
});
Response.Cookies.Append(BrowserAuthOptions.CsrfCookie,
Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(), new CookieOptions
{
Secure = true,
HttpOnly = false,
SameSite = SameSiteMode.Strict,
Path = "/"
});
}
private void ClearCookies()
{
Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" });
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/browser-auth" });
Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" });
}
private void EnsureTrustedOrigin()
{
var origin = Request.Headers.Origin.ToString();
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Scheme, Request.Scheme, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(uri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase))
{
throw new BrowserOriginException();
}
}
private async Task<Guid?> ResolveTenantIdAsync(AuthRealm realm, string? tenantCode, CancellationToken cancellationToken)
{
if (realm == AuthRealm.Platform)
{
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.");
return null;
}
if (tenantContext.TenantId is { } resolved) return resolved;
if (string.IsNullOrWhiteSpace(tenantCode)) throw new RequiredFieldException("tenantCode is required.");
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
}
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");

View File

@@ -16,50 +16,62 @@ public sealed class CommissionController(
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("settings")]
[EndpointSummary("查询佣金配置")]
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken));
[HttpPut("settings")]
[EndpointSummary("保存佣金配置")]
public async Task<ActionResult<CommissionSettingsItem>> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) =>
Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
[HttpPut("member-rate")]
[EndpointSummary("调整成员佣金比例")]
public async Task<ActionResult<object>> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) =>
Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
[HttpGet("summary")]
[EndpointSummary("查询佣金统计摘要")]
public async Task<ActionResult<CommissionSummaryItem>> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken));
[HttpGet("orders")]
[EndpointSummary("查询佣金来源订单")]
public async Task<ActionResult<CommissionList<CommissionSourceItem>>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
[HttpGet("settlements")]
[EndpointSummary("查询佣金结算单")]
public async Task<ActionResult<CommissionList<CommissionSettlementItemDto>>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) =>
Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
[HttpGet("settlements/export")]
[EndpointSummary("导出佣金结算单")]
public async Task<ActionResult<CommissionExportItem>> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) =>
Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken));
[HttpPost("settlements/generate")]
[EndpointSummary("生成佣金结算单")]
public async Task<ActionResult<CommissionSettlementItemDto>> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) =>
Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken));
[HttpPost("settlements/status")]
[EndpointSummary("更新佣金结算单状态")]
public async Task<ActionResult<CommissionSettlementItemDto>> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) =>
Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
[HttpGet("settlements/proofs")]
[EndpointSummary("查询佣金结算凭证")]
public async Task<ActionResult<CommissionList<CommissionProofItem>>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) =>
Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken));
[HttpPost("settlements/proofs")]
[EndpointSummary("创建佣金结算凭证")]
public async Task<ActionResult<CommissionProofItem>> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) =>
Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken));
[HttpPost("settlements/proofs/status")]
[EndpointSummary("更新佣金结算凭证状态")]
public async Task<ActionResult<CommissionProofItem>> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) =>
Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));

View File

@@ -1,6 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using MassTransit.EntityFrameworkCoreIntegration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Tiku.Infrastructure.Messaging;
namespace Tiku.Api.Controllers;
@@ -8,7 +14,11 @@ namespace Tiku.Api.Controllers;
[AllowAnonymous]
[Produces("application/json")]
[Route("api/health")]
public sealed class HealthController : ControllerBase
public sealed class HealthController(
TikuDbContext dbContext,
IRedisSecurityStore redisSecurityStore,
MessagingOptions messagingOptions,
HealthCheckService healthCheckService) : ControllerBase
{
[HttpGet]
[EndpointSummary("健康检查")]
@@ -21,4 +31,30 @@ public sealed class HealthController : ControllerBase
"tiku-api",
DateTimeOffset.UtcNow));
}
[HttpGet("ready")]
[EndpointSummary("依赖就绪检查")]
public async Task<ActionResult<object>> Ready(CancellationToken cancellationToken)
{
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
var rabbitHealth = await healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("ready"),
cancellationToken);
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
var outboxPending = database
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
: -1;
var ready = database && redis && rabbitMq;
var response = new
{
status = ready ? "ready" : "not_ready",
database,
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
outbox = new { pending = outboxPending },
checkedAt = DateTimeOffset.UtcNow
};
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
}
}

View File

@@ -15,6 +15,8 @@ public sealed class MeController(
TikuDbContext dbContext) : ControllerBase
{
[HttpGet]
[EndpointSummary("获取当前登录用户")]
[EndpointDescription("根据 Bearer Token 返回当前用户基础信息和活跃租户成员摘要。")]
public async Task<ActionResult<MeResponse>> Get(CancellationToken cancellationToken)
{
if (currentUser.UserId is null)

View File

@@ -13,6 +13,7 @@ public sealed class SecurityDiagnosticsController(
{
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
[HttpGet("authenticated")]
[EndpointSummary("诊断已登录用户上下文")]
public ActionResult<object> Authenticated()
{
return Ok(new
@@ -24,6 +25,7 @@ public sealed class SecurityDiagnosticsController(
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
[HttpGet("tenant-member")]
[EndpointSummary("诊断当前租户成员上下文")]
public ActionResult<object> TenantMember()
{
return Ok(new
@@ -34,6 +36,7 @@ public sealed class SecurityDiagnosticsController(
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[HttpGet("tenant-admin")]
[EndpointSummary("诊断当前租户管理员上下文")]
public ActionResult<object> TenantAdmin()
{
return Ok(new

View File

@@ -15,6 +15,7 @@ public sealed class TaxonomyController(
ITaxonomyService taxonomyService) : ControllerBase
{
[HttpGet]
[EndpointSummary("查询租户分类节点")]
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
{
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
@@ -22,6 +23,7 @@ public sealed class TaxonomyController(
[HttpPost]
[Authorize(Policy = BackendPermissions.TenantContentManage)]
[EndpointSummary("创建租户分类节点")]
public Task<TaxonomyNodeItem> Create(
CreateTaxonomyNodeDto request,
CancellationToken cancellationToken)

View File

@@ -15,12 +15,14 @@ public sealed class TenantFrontendConfigController(
ITenantFrontendConfigService frontendConfigService) : ControllerBase
{
[HttpGet]
[EndpointSummary("查询租户前端配置")]
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
{
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
}
[HttpPut("draft")]
[EndpointSummary("保存租户前端配置草稿")]
public Task<TenantFrontendConfigItem> SaveDraft(
SaveTenantFrontendConfigDraftDto request,
CancellationToken cancellationToken)
@@ -32,6 +34,7 @@ public sealed class TenantFrontendConfigController(
}
[HttpPost("publish")]
[EndpointSummary("发布租户前端配置")]
public Task<TenantFrontendConfigItem> Publish(
PublishTenantFrontendConfigDto request,
CancellationToken cancellationToken)

View File

@@ -17,6 +17,8 @@ public sealed class TenantsController(
TikuDbContext dbContext) : ControllerBase
{
[HttpGet("current")]
[EndpointSummary("查询当前租户")]
[EndpointDescription("返回当前请求租户及当前用户在该租户内的成员角色。")]
public async Task<ActionResult<CurrentTenantResponse>> GetCurrent(CancellationToken cancellationToken)
{
if (currentUser.UserId is null || currentTenant.TenantId is null)

View File

@@ -3,11 +3,24 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.RateLimiting;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Infrastructure.Security;
namespace Tiku.Api.Middleware;
public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
public sealed class AuthRateLimitPartitionMiddleware(
RequestDelegate next,
IRedisSecurityStore redisSecurityStore,
Microsoft.Extensions.Options.IOptions<AuthRateLimitOptions> options)
{
public AuthRateLimitPartitionMiddleware(RequestDelegate next)
: this(
next,
new NullRedisSecurityStore(),
Microsoft.Extensions.Options.Options.Create(new AuthRateLimitOptions()))
{
}
public async Task InvokeAsync(HttpContext context)
{
var policy = context.GetEndpoint()?
@@ -24,11 +37,70 @@ public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
{
await CaptureAccountHashAsync(context, propertyName);
if (redisSecurityStore.IsConfigured)
{
await ConsumeDistributedLimitAsync(context, policy!);
if (context.Response.HasStarted)
{
return;
}
}
}
await next(context);
}
private async Task ConsumeDistributedLimitAsync(HttpContext context, string policyName)
{
var ip = Hash(context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip");
var account = context.Items.TryGetValue(AuthRateLimitPartitionKey.AccountHashItemKey, out var value)
? value as string ?? "unknown-account"
: "unknown-account";
var isPassword = policyName == AuthRateLimitPolicies.Password;
var limit = isPassword ? options.Value.PasswordPermitLimit : options.Value.SmsPermitLimit;
var window = TimeSpan.FromSeconds(isPassword
? options.Value.PasswordWindowSeconds
: options.Value.SmsWindowSeconds);
DistributedRateLimitResult result;
try
{
result = await redisSecurityStore.ConsumeAsync(
[
new DistributedRateLimitBucket($"{policyName}:ip:{ip}", limit * 4, window),
new DistributedRateLimitBucket($"{policyName}:ip-account:{ip}:{account}", limit, window)
], context.RequestAborted);
}
catch (RedisSecurityUnavailableException)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new
{
title = "Authentication security service is unavailable.",
status = StatusCodes.Status503ServiceUnavailable,
code = "auth_security_unavailable",
traceId = context.TraceIdentifier
}, context.RequestAborted);
return;
}
if (!result.Allowed)
{
if (result.RetryAfter is { } retryAfter)
{
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.Response.WriteAsJsonAsync(new
{
title = "Too many requests.",
status = StatusCodes.Status429TooManyRequests,
code = "rate_limited",
traceId = context.TraceIdentifier
}, context.RequestAborted);
}
}
private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName)
{
context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384);

View File

@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Tiku.Api.Options;
namespace Tiku.Api.Middleware;
public sealed class BrowserCsrfMiddleware(
RequestDelegate next,
IOptions<BrowserAuthOptions> options)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsUnsafe(context.Request.Method) || !IsBrowserCookieRequest(context.Request))
{
await next(context);
return;
}
if (!IsTrustedOrigin(context.Request) || !HasValidCsrfToken(context.Request))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
title = "Browser request origin or CSRF token is invalid.",
status = StatusCodes.Status403Forbidden,
code = "browser_csrf_rejected",
traceId = context.TraceIdentifier
}, context.RequestAborted);
return;
}
await next(context);
}
private static bool IsUnsafe(string method) =>
!HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsOptions(method);
private static bool IsBrowserCookieRequest(HttpRequest request) =>
request.Cookies.ContainsKey(BrowserAuthOptions.AccessCookie) ||
request.Cookies.ContainsKey(BrowserAuthOptions.RefreshCookie);
private bool IsTrustedOrigin(HttpRequest request)
{
var origin = request.Headers.Origin.ToString().Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri))
{
return false;
}
var sameOrigin = string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
return sameOrigin || options.Value.AllowedOrigins.Any(allowed =>
string.Equals(allowed.Trim().TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase));
}
private static bool HasValidCsrfToken(HttpRequest request)
{
var cookie = request.Cookies[BrowserAuthOptions.CsrfCookie];
var header = request.Headers[BrowserAuthOptions.CsrfHeader].ToString();
if (string.IsNullOrWhiteSpace(cookie) || string.IsNullOrWhiteSpace(header))
{
return false;
}
var left = System.Text.Encoding.UTF8.GetBytes(cookie);
var right = System.Text.Encoding.UTF8.GetBytes(header);
return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right);
}
}

View File

@@ -50,6 +50,12 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is BrowserOriginException)
{
await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, "browser_origin_rejected");
return;
}
if (exception is TenantContextConflictException)
{
await WriteProblemAsync(
@@ -380,6 +386,7 @@ public sealed class ExceptionHandlingMiddleware(
"tenant_access_denied" => StatusCodes.Status403Forbidden,
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
"auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable,
"auth_security_unavailable" => StatusCodes.Status503ServiceUnavailable,
"session_revoked" => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status401Unauthorized
};
@@ -494,7 +501,7 @@ public sealed class ExceptionHandlingMiddleware(
{
return code switch
{
"platform_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden,
"platform_access_denied" or "tenant_access_denied" or "capability_not_available" => StatusCodes.Status403Forbidden,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};

View File

@@ -0,0 +1,12 @@
namespace Tiku.Api.Options;
public sealed class BrowserAuthOptions
{
public const string SectionName = "BrowserAuth";
public const string AccessCookie = "__Host-tiku-at";
public const string RefreshCookie = "__Secure-tiku-rt";
public const string CsrfCookie = "__Host-tiku-csrf";
public const string CsrfHeader = "X-CSRF-Token";
public string[] AllowedOrigins { get; set; } = [];
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Configuration;
using System.Net;
using Tiku.Application.Security;
namespace Tiku.Api.Options;
@@ -41,6 +42,32 @@ public static class OptionsValidation
return options.AllowedOrigins.All(IsHttpOrigin);
}
public static bool BeValidTenantResolutionOptions(
TenantResolutionOptions options,
IConfiguration configuration,
bool isProduction)
{
if (!isProduction)
{
return true;
}
var platformHosts = options.PlatformHosts
.Where(host => !string.IsNullOrWhiteSpace(host))
.Select(host => host.Trim())
.ToArray();
var hasFormalHost = platformHosts.Any(host =>
!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(host, "::1", StringComparison.OrdinalIgnoreCase));
var allowedHosts = configuration["AllowedHosts"];
return hasFormalHost &&
options.TrustedProxyAddresses.Any(address => IPAddress.TryParse(address, out _)) &&
!string.IsNullOrWhiteSpace(allowedHosts) &&
!allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Contains("*", StringComparer.Ordinal);
}
private static bool IsHttpOrigin(string origin)
{
return Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&

View File

@@ -1,6 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Tiku.Application.Security;
namespace Tiku.Api.Security;
@@ -73,7 +74,10 @@ internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext ac
Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null;
}
internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
internal sealed class TenantPermissionAuthorizationHandler(
ICurrentAccessContext accessContext,
ICapabilityAccessEvaluator capabilityAccessEvaluator,
IHttpContextAccessor httpContextAccessor) :
AuthorizationHandler<TenantPermissionRequirement>
{
protected override async Task HandleRequirementAsync(
@@ -86,11 +90,27 @@ internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext
}
var access = await accessContext.GetAsync();
if (access.HasTenantPermission(requirement.PermissionCode))
var moduleCode = ResolveModuleCode(requirement.PermissionCode);
var operation = IsSafeMethod(httpContextAccessor.HttpContext?.Request.Method)
? CapabilityOperation.Read
: CapabilityOperation.Write;
if (access.TenantId is { } tenantId &&
access.HasTenantPermission(requirement.PermissionCode) &&
await capabilityAccessEvaluator.IsAllowedAsync(tenantId, moduleCode, operation))
{
context.Succeed(requirement);
}
}
private static string ResolveModuleCode(string permissionCode)
{
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
}
private static bool IsSafeMethod(string? method) =>
method is not null &&
(HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method));
}
internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
@@ -176,6 +196,8 @@ public static class AccessAuthorizationServiceCollectionExtensions
{
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.TryAddScoped<ICapabilityAccessEvaluator, CompatibilityCapabilityAccessEvaluator>();
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
@@ -252,3 +274,18 @@ public static class AccessAuthorizationServiceCollectionExtensions
return services;
}
}
internal sealed class CompatibilityCapabilityAccessEvaluator : ICapabilityAccessEvaluator
{
public Task<bool> IsAllowedAsync(
Guid tenantId,
string moduleCode,
CapabilityOperation operation,
CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<IReadOnlySet<string>> GetEnabledModulesAsync(
Guid tenantId,
CapabilityOperation operation = CapabilityOperation.Read,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.Ordinal));
}

View File

@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Routing;
using Tiku.Application.Security;
namespace Tiku.Api.Security;
public sealed record EndpointAuthorizationMetadata(
string Realm,
string? Module,
string? Permission,
CapabilityOperation Operation,
bool RequiresAllDataScope,
string AuditAction);
internal sealed class EndpointAuthorizationMetadataConvention : IApplicationModelConvention
{
public void Apply(ApplicationModel application)
{
foreach (var controller in application.Controllers)
{
foreach (var action in controller.Actions)
{
var anonymous = controller.Attributes.OfType<AllowAnonymousAttribute>().Any() ||
action.Attributes.OfType<AllowAnonymousAttribute>().Any();
if (anonymous)
{
continue;
}
var policies = controller.Attributes.OfType<AuthorizeAttribute>()
.Concat(action.Attributes.OfType<AuthorizeAttribute>())
.Select(attribute => attribute.Policy)
.Where(policy => !string.IsNullOrWhiteSpace(policy))
.Cast<string>()
.ToArray();
var permission = policies.FirstOrDefault(policy =>
BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy));
var realm = permission is not null && BackendPermissions.Platform.Contains(permission) ||
policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal))
? "platform"
: permission is not null && BackendPermissions.Tenant.Contains(permission) ||
policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal))
? "tenant"
: "authenticated";
var module = permission is null ? null : ResolveModule(permission);
var httpMethods = action.Attributes.OfType<HttpMethodAttribute>()
.SelectMany(attribute => attribute.HttpMethods)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var operation = httpMethods.All(IsSafeMethod)
? CapabilityOperation.Read
: CapabilityOperation.Write;
var route = $"{controller.ControllerName}.{action.ActionName}";
var metadata = new EndpointAuthorizationMetadata(
realm,
module,
permission,
operation,
policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) ||
policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal),
$"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}");
foreach (var selector in action.Selectors)
{
selector.EndpointMetadata.Add(metadata);
}
}
}
}
private static string ResolveModule(string permission)
{
var parts = permission.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permission.ToLowerInvariant();
}
private static bool IsSafeMethod(string method) =>
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
}

View File

@@ -65,6 +65,18 @@
"DeviceRequestsPerHour": 10
}
},
"Redis": {
"KeyPrefix": "tiku"
},
"RabbitMq": {
"Host": "",
"VirtualHost": "/",
"Username": "",
"Password": ""
},
"BrowserAuth": {
"AllowedOrigins": []
},
"Storage": {
"DefaultProvider": "aliyun_oss",
"DefaultBucket": "tenant-assets",