Files
tiku-backend.net/Tiku.Api/Configuration/AuthenticationExtensions.cs

236 lines
9.7 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.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<Microsoft.Extensions.Options.IOptions<TenantResolutionOptions>>().Value;
var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.');
var isPlatformHost = resolutionOptions.PlatformHosts.Any(host =>
string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase));
var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService<ITenantContext>();
var tenantInitializer = context.HttpContext.RequestServices
.GetRequiredService<ITenantContextInitializer>();
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 sessionStore = context.HttpContext.RequestServices.GetRequiredService<IAuthSessionStore>();
var session = await sessionStore.ValidateAccessSessionAsync(
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" }
});
}
}