forked from gongxuegit/tiku-backend.net
feat(security): add distributed authorization foundation
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
251
Tiku.Api/Controllers/BrowserAuthController.cs
Normal file
251
Tiku.Api/Controllers/BrowserAuthController.cs
Normal 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.");
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
68
Tiku.Api/Middleware/BrowserCsrfMiddleware.cs
Normal file
68
Tiku.Api/Middleware/BrowserCsrfMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
12
Tiku.Api/Options/BrowserAuthOptions.cs
Normal file
12
Tiku.Api/Options/BrowserAuthOptions.cs
Normal 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; } = [];
|
||||
}
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
79
Tiku.Api/Security/EndpointAuthorizationMetadata.cs
Normal file
79
Tiku.Api/Security/EndpointAuthorizationMetadata.cs
Normal 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);
|
||||
}
|
||||
@@ -65,6 +65,18 @@
|
||||
"DeviceRequestsPerHour": 10
|
||||
}
|
||||
},
|
||||
"Redis": {
|
||||
"KeyPrefix": "tiku"
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"BrowserAuth": {
|
||||
"AllowedOrigins": []
|
||||
},
|
||||
"Storage": {
|
||||
"DefaultProvider": "aliyun_oss",
|
||||
"DefaultBucket": "tenant-assets",
|
||||
|
||||
Reference in New Issue
Block a user