226 lines
9.6 KiB
C#
226 lines
9.6 KiB
C#
using System.Globalization;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Tiku.Api.Options;
|
|
using Tiku.Api.Security;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Tenancy;
|
|
|
|
namespace Tiku.Api.Configuration;
|
|
|
|
internal static class AuthenticationExtensions
|
|
{
|
|
internal static IServiceCollection AddApiAuthenticationAndAuthorization(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration,
|
|
IHostEnvironment environment)
|
|
{
|
|
services.AddOptions<JwtOptions>()
|
|
.Bind(configuration.GetSection("Security:Jwt"))
|
|
.ValidateDataAnnotations()
|
|
.Validate(
|
|
options => OptionsValidation.BeValidJwtOptions(options, environment.IsProduction()),
|
|
"Production JWT signing key must be explicitly configured and cannot use the development key.")
|
|
.ValidateOnStart();
|
|
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)
|
|
.AddJwtBearer(options => ConfigureJwtBearer(options, jwtOptions));
|
|
|
|
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
|
.Configure<IJwtKeyRing>((options, keyRing) =>
|
|
{
|
|
options.TokenValidationParameters.IssuerSigningKeys = keyRing.ValidationKeys;
|
|
options.TokenValidationParameters.TryAllIssuerSigningKeys = false;
|
|
options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) =>
|
|
string.IsNullOrWhiteSpace(kid)
|
|
? []
|
|
: keyRing.ValidationKeys.Where(key =>
|
|
string.Equals(key.KeyId, kid, StringComparison.Ordinal));
|
|
});
|
|
|
|
services.AddAuthorization(options =>
|
|
{
|
|
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
|
.RequireAuthenticatedUser()
|
|
.Build();
|
|
options.AddPolicy(
|
|
TikuPolicies.AuthenticatedUser,
|
|
policy => policy.RequireAuthenticatedUser());
|
|
options.AddPolicy(
|
|
TikuPolicies.TenantAdmin,
|
|
policy => policy.RequireAuthenticatedUser());
|
|
});
|
|
services.AddTikuRbacAuthorization();
|
|
services.AddSingleton<IAuthorizationMiddlewareResultHandler,
|
|
AuditingAuthorizationMiddlewareResultHandler>();
|
|
|
|
return services;
|
|
}
|
|
|
|
private static void ConfigureJwtBearer(JwtBearerOptions options, JwtOptions jwtOptions)
|
|
{
|
|
options.MapInboundClaims = false;
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = jwtOptions.Issuer,
|
|
ValidateAudience = true,
|
|
ValidAudience = jwtOptions.Audience,
|
|
ValidateIssuerSigningKey = true,
|
|
RequireSignedTokens = true,
|
|
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
|
|
ValidateLifetime = true,
|
|
RequireExpirationTime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(1),
|
|
NameClaimType = TikuClaimTypes.UserId
|
|
};
|
|
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;
|
|
if (!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) ||
|
|
!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.SessionId)?.Value, out var sessionId) ||
|
|
string.IsNullOrWhiteSpace(principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value) ||
|
|
!long.TryParse(
|
|
principal?.FindFirst(JwtRegisteredClaimNames.Iat)?.Value,
|
|
NumberStyles.None,
|
|
CultureInfo.InvariantCulture,
|
|
out _))
|
|
{
|
|
context.Fail("Missing or invalid subject/session/jti/iat claim.");
|
|
return;
|
|
}
|
|
|
|
var realmValue = principal.FindFirst(TikuClaimTypes.Realm)?.Value;
|
|
var realm = string.Equals(realmValue, "tenant", StringComparison.Ordinal)
|
|
? AuthRealm.Tenant
|
|
: string.Equals(realmValue, "platform", StringComparison.Ordinal)
|
|
? AuthRealm.Platform
|
|
: (AuthRealm?)null;
|
|
var tenantIdValue = principal.FindFirst(TikuClaimTypes.TenantId)?.Value;
|
|
var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId)
|
|
? parsedTenantId
|
|
: (Guid?)null;
|
|
if (realm is null || realm == AuthRealm.Tenant != tenantId.HasValue)
|
|
{
|
|
context.Fail("Token scope and tenant claims are inconsistent.");
|
|
return;
|
|
}
|
|
|
|
var resolutionOptions = context.HttpContext.RequestServices
|
|
.GetRequiredService<IOptions<TenantResolutionOptions>>().Value;
|
|
var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.');
|
|
var isPlatformHost = resolutionOptions.PlatformHosts.Any(host =>
|
|
string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase));
|
|
var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService<ITenantContext>();
|
|
var tenantInitializer = context.HttpContext.RequestServices
|
|
.GetRequiredService<ITenantContextInitializer>();
|
|
|
|
if (realm == AuthRealm.Platform)
|
|
{
|
|
if (!isPlatformHost || resolvedTenantContext.IsResolved)
|
|
{
|
|
context.HttpContext.Items["tenant_context_conflict"] = true;
|
|
context.Fail("Platform tokens are only valid on a platform host.");
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (isPlatformHost && !resolvedTenantContext.IsResolved)
|
|
{
|
|
context.HttpContext.Items["tenant_context_conflict"] = true;
|
|
context.Fail("Tenant tokens on a platform host require a matching tenant code.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
tenantInitializer.Initialize(tenantId!.Value, null, TenantResolutionSource.Jwt);
|
|
}
|
|
catch (TenantContextConflictException)
|
|
{
|
|
context.HttpContext.Items["tenant_context_conflict"] = true;
|
|
context.Fail("Authenticated tenant does not match the request host.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
var accessValidator = context.HttpContext.RequestServices.GetRequiredService<IRequestAccessValidator>();
|
|
var session = await accessValidator.ValidateAsync(
|
|
sessionId,
|
|
userId,
|
|
realm.Value,
|
|
tenantId,
|
|
context.HttpContext.RequestAborted);
|
|
if (session is null)
|
|
{
|
|
context.Fail("Session, identity, membership, tenant or role state is no longer valid.");
|
|
return;
|
|
}
|
|
|
|
context.HttpContext.RequestServices.GetRequiredService<IRequestSecurityState>()
|
|
.SetValidatedSession(session);
|
|
}
|
|
|
|
private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context)
|
|
{
|
|
if (!context.HttpContext.Items.ContainsKey("tenant_context_conflict")) return;
|
|
|
|
context.HandleResponse();
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
|
{
|
|
Title = "Authenticated tenant does not match the request host.",
|
|
Status = StatusCodes.Status403Forbidden,
|
|
Extensions = { ["code"] = "tenant_context_conflict" }
|
|
});
|
|
}
|
|
} |