Ensure that SignalR works with the webpanel

- Fix issue with CORS preventing static file browsing.
- AllowCredentials in CORS. Switch from `AllowAnyOrigin` to a wildcard matching `Func` to bypass the CORS specification that says you can't do that.
- Add workaround for legacy SignalR `access_token` query string.
- Get token `nbf` from claims rather than through the composition root.
This commit is contained in:
Jordan Dominion
2023-11-05 11:23:47 -05:00
parent ed8453f08e
commit fa13869ea2
4 changed files with 66 additions and 55 deletions
+41 -30
View File
@@ -473,33 +473,6 @@ namespace Tgstation.Server.Host.Core
logger.LogTrace("Swagger API generation enabled");
}
// Enable endpoint routing
applicationBuilder.UseRouting();
// Set up CORS based on configuration if necessary
Action<CorsPolicyBuilder> corsBuilder = null;
if (controlPanelConfiguration.AllowAnyOrigin)
{
logger.LogTrace("Access-Control-Allow-Origin: *");
corsBuilder = builder => builder.AllowAnyOrigin();
}
else if (controlPanelConfiguration.AllowedOrigins?.Count > 0)
{
logger.LogTrace("Access-Control-Allow-Origin: {allowedOrigins}", String.Join(',', controlPanelConfiguration.AllowedOrigins));
corsBuilder = builder => builder.WithOrigins(controlPanelConfiguration.AllowedOrigins.ToArray());
}
var originalBuilder = corsBuilder;
corsBuilder = builder =>
{
builder
.AllowAnyHeader()
.AllowAnyMethod()
.SetPreflightMaxAge(TimeSpan.FromDays(1));
originalBuilder?.Invoke(builder);
};
applicationBuilder.UseCors(corsBuilder);
// spa loading if necessary
if (controlPanelConfiguration.Enable)
{
@@ -518,6 +491,34 @@ namespace Tgstation.Server.Host.Core
logger.LogTrace("Web control panel disabled!");
#endif
// Enable endpoint routing
applicationBuilder.UseRouting();
// Set up CORS based on configuration if necessary
Action<CorsPolicyBuilder> corsBuilder = null;
if (controlPanelConfiguration.AllowAnyOrigin)
{
logger.LogTrace("Access-Control-Allow-Origin: *");
corsBuilder = builder => builder.SetIsOriginAllowed(_ => true);
}
else if (controlPanelConfiguration.AllowedOrigins?.Count > 0)
{
logger.LogTrace("Access-Control-Allow-Origin: {allowedOrigins}", String.Join(',', controlPanelConfiguration.AllowedOrigins));
corsBuilder = builder => builder.WithOrigins(controlPanelConfiguration.AllowedOrigins.ToArray());
}
var originalBuilder = corsBuilder;
corsBuilder = builder =>
{
builder
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromDays(1));
originalBuilder?.Invoke(builder);
};
applicationBuilder.UseCors(corsBuilder);
// validate the API version
applicationBuilder.UseApiCompatibility();
@@ -596,10 +597,20 @@ namespace Tgstation.Server.Host.Core
jwtBearerOptions.TokenValidationParameters = tokenFactory?.ValidationParameters ?? throw new InvalidOperationException("tokenFactory not initialized!");
jwtBearerOptions.Events = new JwtBearerEvents
{
OnTokenValidated = tokenValidatedContext =>
OnMessageReceived = context =>
{
var acf = tokenValidatedContext.HttpContext.RequestServices.GetRequiredService<AuthenticationContextFactory>();
acf.SetTokenNbf(tokenValidatedContext.SecurityToken.ValidFrom);
if (String.IsNullOrWhiteSpace(context.Token))
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!String.IsNullOrWhiteSpace(accessToken) &&
path.StartsWithSegments(Routes.HubsRoot, StringComparison.OrdinalIgnoreCase))
{
context.Token = accessToken;
}
}
return Task.CompletedTask;
},
};
@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Tokens;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Rights;
@@ -48,7 +49,7 @@ namespace Tgstation.Server.Host.Security
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub);
if (userIdClaim == default)
throw new InvalidOperationException("Missing required claim!");
throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!");
long userId;
try
@@ -60,9 +61,26 @@ namespace Tgstation.Server.Host.Security
throw new InvalidOperationException("Failed to parse user ID!", e);
}
var nbfClaim = principal.FindFirst(JwtRegisteredClaimNames.Nbf);
if (nbfClaim == default)
throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Nbf}' claim!");
DateTimeOffset nbf;
try
{
nbf = new DateTimeOffset(
EpochTime.DateTime(
Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture)));
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to parse nbf!", ex);
}
var authenticationContext = await authenticationContextFactory.CreateAuthenticationContext(
userId,
apiHeaders?.InstanceId,
nbf,
CancellationToken.None); // DCT: None available
if (authenticationContext.Valid)
@@ -46,11 +46,6 @@ namespace Tgstation.Server.Host.Security
/// </summary>
readonly AuthenticationContext currentAuthenticationContext;
/// <summary>
/// The <see cref="DateTimeOffset"/> the request's token must be valid after.
/// </summary>
DateTimeOffset? validAfter;
/// <summary>
/// 1 if <see cref="currentAuthenticationContext"/> was initialized, 0 otherwise.
/// </summary>
@@ -80,27 +75,12 @@ namespace Tgstation.Server.Host.Security
/// <inheritdoc />
public void Dispose() => currentAuthenticationContext.Dispose();
/// <summary>
/// Populate <see cref="validAfter"/> with a given <paramref name="tokenNbf"/>.
/// </summary>
/// <param name="tokenNbf">The <see cref="DateTimeOffset"/> an issued token is not valid before.</param>
public void SetTokenNbf(DateTimeOffset tokenNbf)
{
if (validAfter.HasValue)
throw new InvalidOperationException("SetTokenNbf called multiple times!");
validAfter = tokenNbf;
}
/// <inheritdoc />
public async ValueTask<IAuthenticationContext> CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken)
public async ValueTask<IAuthenticationContext> CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref initialized, 1) != 0)
throw new InvalidOperationException("Authentication context has already been loaded");
if (!validAfter.HasValue)
throw new InvalidOperationException("SetTokenNbf has not been called!");
var user = await databaseContext
.Users
.AsQueryable()
@@ -122,7 +102,7 @@ namespace Tgstation.Server.Host.Security
systemIdentity = identityCache.LoadCachedIdentity(user);
else
{
if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validAfter.Value)
if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > notBefore)
{
logger.LogDebug("Rejecting token for user {userId} created before last password update: {lastPasswordUpdate}", userId, user.LastPasswordUpdate.Value);
return currentAuthenticationContext;
@@ -1,4 +1,5 @@
using System.Threading;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Security
@@ -13,8 +14,9 @@ namespace Tgstation.Server.Host.Security
/// </summary>
/// <param name="userId">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="Models.User"/>.</param>
/// <param name="instanceId">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="Models.Instance"/> for the operation.</param>
/// <param name="notBefore">The <see cref="DateTimeOffset"/> the login must not be from before.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the created <see cref="IAuthenticationContext"/>.</returns>
ValueTask<IAuthenticationContext> CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken);
ValueTask<IAuthenticationContext> CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken);
}
}