diff --git a/README.md b/README.md index d2830e93d9..8b5432ccf0 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ On Linux, as long as OpenDream and TGS do not use the same .NET major version, y 1. Install `tgstation-server` using any of the above methods. 1. [Download the Linux SDK binaries](https://dotnet.microsoft.com/en-us/download/dotnet/7.0) for your selected architecture. -1. Extract everything EXCEPT the `dotnet` executable, `LICENSE.txt``, and `ThirdPartyNotices.txt` in the `.tar.gz` on top of the existing installation directory `/usr/share/dotnet/` +1. Extract everything EXCEPT the `dotnet` executable, `LICENSE.txt`, and `ThirdPartyNotices.txt` in the `.tar.gz` on top of the existing installation directory `/usr/share/dotnet/` 1. Run `sudo chown -R root /usr/share/dotnet` You should now be able to run the `dotnet --list-sdks` command and see an entry for `7.0.XXX [/usr/share/dotnet/sdk]`. diff --git a/build/Version.props b/build/Version.props index c27dc59c94..61ce004864 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,12 +3,12 @@ - 6.0.0 + 6.0.1 5.0.0 10.0.0 7.0.0 - 13.0.0 - 15.0.0 + 13.0.1 + 15.0.1 7.0.0 5.7.0 1.4.1 diff --git a/src/Tgstation.Server.Api/Models/Response/ErrorMessageResponse.cs b/src/Tgstation.Server.Api/Models/Response/ErrorMessageResponse.cs index 914d9ecc3a..70cb64c530 100644 --- a/src/Tgstation.Server.Api/Models/Response/ErrorMessageResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/ErrorMessageResponse.cs @@ -1,6 +1,8 @@ using System; using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; + namespace Tgstation.Server.Api.Models.Response { /// @@ -28,6 +30,7 @@ namespace Tgstation.Server.Api.Models.Response /// The of the . /// [EnumDataType(typeof(ErrorCode))] + [JsonProperty(Required = Required.Always)] public ErrorCode ErrorCode { get; set; } /// diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs index 6f7ad59c5a..da46f6e678 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -687,9 +687,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers channelId, discordChannelResponse.LogFormat()); - remapRequired |= !(discordChannelResponse.Error is RestResultError restResultError + var remapConditional = !(discordChannelResponse.Error is RestResultError restResultError && (restResultError.Error?.Code == DiscordError.MissingAccess || restResultError.Error?.Code == DiscordError.UnknownChannel)); + + if (remapConditional) + { + Logger.Log( + remapRequired + ? LogLevel.Trace + : LogLevel.Debug, + "Error on channel {channelId} is not an access/thread issue. Will retry remap...", + channelId); + remapRequired = true; + } + return null; } @@ -721,7 +733,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers "Error retrieving discord guild {guildID}: {result}", guildId, guildsResponse.LogFormat()); - remapRequired |= true; + remapRequired = true; } return null; diff --git a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs index d69947d4ba..7e450874f5 100644 --- a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs +++ b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs @@ -123,7 +123,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The value of the route. /// The to use. - [Route("/{**appRoute}")] + [Route("{**appRoute}")] [HttpGet] public IActionResult Get([FromRoute] string appRoute) { diff --git a/src/Tgstation.Server.Host/Controllers/RootController.cs b/src/Tgstation.Server.Host/Controllers/RootController.cs index 9ae6e7e942..e075aa8332 100644 --- a/src/Tgstation.Server.Host/Controllers/RootController.cs +++ b/src/Tgstation.Server.Host/Controllers/RootController.cs @@ -94,21 +94,21 @@ namespace Tgstation.Server.Host.Controllers [AllowAnonymous] public IActionResult Index() { - const string ApiDocumentationRoute = "/" + SwaggerConfiguration.DocumentationSiteRouteExtension; var panelEnabled = controlPanelConfiguration.Enable; var apiDocsEnabled = generalConfiguration.HostApiDocumentation; + var controlPanelRoute = ControlPanelController.ControlPanelRoute.TrimStart('/'); if (panelEnabled ^ apiDocsEnabled) if (panelEnabled) - return Redirect(ControlPanelController.ControlPanelRoute); + return Redirect(controlPanelRoute); else - return Redirect(ApiDocumentationRoute); + return Redirect(SwaggerConfiguration.DocumentationSiteRouteExtension); Dictionary? links; if (panelEnabled) links = new Dictionary() { - { "Web Control Panel", ControlPanelController.ControlPanelRoute.TrimStart('/') }, + { "Web Control Panel", controlPanelRoute }, { "API Documentation", SwaggerConfiguration.DocumentationSiteRouteExtension }, }; else diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 3b80d9da38..7e16addfe8 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -16,7 +16,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; @@ -629,7 +628,6 @@ namespace Tgstation.Server.Host.Core .GetRequiredService() .CurrentAuthenticationContext); services.AddScoped(); - services.AddScoped(); services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextAuthorizationFilter.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextAuthorizationFilter.cs deleted file mode 100644 index b009f599ad..0000000000 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextAuthorizationFilter.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Security.Claims; - -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Logging; - -using Tgstation.Server.Host.Models; - -namespace Tgstation.Server.Host.Security -{ - /// - /// An that maps s using an . - /// - sealed class AuthenticationContextAuthorizationFilter : IAuthorizationFilter - { - /// - /// The for the . - /// - readonly IAuthenticationContext authenticationContext; - - /// - /// The for the . - /// - readonly ILogger logger; - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - /// The value of . - public AuthenticationContextAuthorizationFilter(IAuthenticationContext authenticationContext, ILogger logger) - { - this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - public void OnAuthorization(AuthorizationFilterContext context) - { - if (!authenticationContext.Valid) - { - logger.LogTrace("authenticationContext is invalid!"); - context.Result = new UnauthorizedResult(); - return; - } - - if (authenticationContext.User.Require(x => x.Enabled)) - return; - - logger.LogTrace("authenticationContext is for a disabled user!"); - context.Result = new ForbidResult(); - } - } -} diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs index c5b1fe0ce2..d649e2f7ff 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs @@ -83,32 +83,28 @@ namespace Tgstation.Server.Host.Security nbf, CancellationToken.None); // DCT: None available - if (authenticationContext.Valid) + var enumerator = Enum.GetValues(typeof(RightsType)); + var claims = new List(); + foreach (RightsType rightType in enumerator) { - var enumerator = Enum.GetValues(typeof(RightsType)); - var claims = new List(); - foreach (RightsType rightType in enumerator) - { - // if there's no instance user, do a weird thing and add all the instance roles - // we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid - // if user is null that means they got the token with an expired password - var rightAsULong = authenticationContext.User == null - || (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) - ? ~0UL - : authenticationContext.GetRight(rightType); - var rightEnum = RightsHelper.RightToType(rightType); - var right = (Enum)Enum.ToObject(rightEnum, rightAsULong); - foreach (Enum enumeratedRight in Enum.GetValues(rightEnum)) - if (right.HasFlag(enumeratedRight)) - claims.Add( - new Claim( - ClaimTypes.Role, - RightsHelper.RoleName(rightType, enumeratedRight))); - } - - principal.AddIdentity(new ClaimsIdentity(claims)); + // if there's a bad condition, do a weird thing and add all the roles + // we need it so we can get to TgsAuthorizeAttribute where we can properly decide between BadRequest and Forbid + var rightAsULong = !authenticationContext.Valid + || (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) + ? ~0UL + : authenticationContext.GetRight(rightType); + var rightEnum = RightsHelper.RightToType(rightType); + var right = (Enum)Enum.ToObject(rightEnum, rightAsULong); + foreach (Enum enumeratedRight in Enum.GetValues(rightEnum)) + if (right.HasFlag(enumeratedRight)) + claims.Add( + new Claim( + ClaimTypes.Role, + RightsHelper.RoleName(rightType, enumeratedRight))); } + principal.AddIdentity(new ClaimsIdentity(claims)); + return principal; } } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 80a9f1df92..00df5df54f 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -102,7 +102,7 @@ namespace Tgstation.Server.Host.Security systemIdentity = identityCache.LoadCachedIdentity(user); else { - if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > notBefore) + 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; diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index ac6cbbc08f..2f7faf3923 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -64,7 +64,7 @@ 1. If a valid authentication context is returned from the [IAuthenticationContextFactory](./IAuthenticationContextFactory.cs), the [AuthenticationContextClaimsTransformation](./AuthenticationContextClaimsTransformation.cs) uses the context to add claims for each permission bit to the user's identity principal. - Internally, ASP.NET Core uses this to determine whether or not a request to an endpoint will 403 or not based on the parameters of its [TgsAuthorizeAttribute](./TgsAuthorizeAttribute.cs). 1. The authorization filter is invoked - - For non-SignalR hub requests, this is the [AuthenticationContextAuthorizationFilter](./AuthenticationContextAuthorizationFilter.cs). It does two simple things: + - For non-SignalR hub requests, this is the `IAuthorizationFilter` part of the [TgsAuthorizeAttribute](./TgsAuthorizeAttribute.cs). It does two simple things: 1. It checks the validity of the scope's [IAuthenticationContext](./IAuthenticationContext.cs). If it is invalid (indicating the user is not authorized either due to not existing (Only possible with a forged and signed JWT) or if their token was outdated compared to the last time their password or `Enabled` status was updated), HTTP 401 will be returned. 1. It checks the user's `Enabled` status. If the user is disabled, HTTP 403 will be returned. - For SignalR hub requests, this is the [AuthorizationContextHubFilter](./AuthorizationContextHubFilter.cs). diff --git a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs index 0fc4c11c1a..0dda24b472 100644 --- a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs @@ -1,8 +1,13 @@ using System; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { @@ -11,7 +16,7 @@ namespace Tgstation.Server.Host.Security /// #pragma warning disable CA1019 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - sealed class TgsAuthorizeAttribute : AuthorizeAttribute + sealed class TgsAuthorizeAttribute : AuthorizeAttribute, IAuthorizationFilter { /// /// Gets the associated with the if any. @@ -114,5 +119,26 @@ namespace Tgstation.Server.Host.Security Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.InstancePermissionSet; } + + /// + public void OnAuthorization(AuthorizationFilterContext context) + { + var services = context.HttpContext.RequestServices; + var authenticationContext = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + + if (!authenticationContext.Valid) + { + logger.LogTrace("authenticationContext is invalid!"); + context.Result = new UnauthorizedResult(); + return; + } + + if (authenticationContext.User.Require(x => x.Enabled)) + return; + + logger.LogTrace("authenticationContext is for a disabled user!"); + context.Result = new ForbidResult(); + } } } diff --git a/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs index 8cc7d0b6fb..0cd00f5330 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/DeploymentTest.cs @@ -123,7 +123,7 @@ namespace Tgstation.Server.Tests.Live.Instance var updatedDM = await dreamMakerClient.Update(new DreamMakerRequest { ProjectName = "tests/DMAPI/ApiFree/api_free", - ApiValidationPort = dmPort + ApiValidationPort = dmPort, }, cancellationToken); Assert.AreEqual(dmPort, updatedDM.ApiValidationPort); Assert.AreEqual("tests/DMAPI/ApiFree/api_free", updatedDM.ProjectName); @@ -139,10 +139,10 @@ namespace Tgstation.Server.Tests.Live.Instance var updatedDD = await dreamDaemonClient.Update(new DreamDaemonRequest { - StartupTimeout = 30, + StartupTimeout = 60, Port = ddPort }, cancellationToken); - Assert.AreEqual(30U, updatedDD.StartupTimeout); + Assert.AreEqual(60U, updatedDD.StartupTimeout); Assert.AreEqual(ddPort, updatedDD.Port); async Task CompileAfterByondInstall() @@ -153,7 +153,7 @@ namespace Tgstation.Server.Tests.Live.Instance var deployJobTask = CompileAfterByondInstall(); var deployJob = await deployJobTask; - var deploymentJobWaitTask = WaitForJob(deployJob, 40, true, ErrorCode.DeploymentNeverValidated, cancellationToken); + var deploymentJobWaitTask = WaitForJob(deployJob, 120, true, ErrorCode.DeploymentNeverValidated, cancellationToken); await CheckDreamDaemonPriority(deploymentJobWaitTask, cancellationToken); diff --git a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs index bbe349ca5c..555760d91f 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs @@ -159,10 +159,27 @@ namespace Tgstation.Server.Tests.Live.Instance Origin = new Uri(Origin), }, cancellationToken).AsTask(); - var dmUpdateRequest = instanceClient.DreamMaker.Update(new DreamMakerRequest + async Task UpdateDMSettings() { - ApiValidationPort = dmPort, - }, cancellationToken); + for (var i = 0; i < 5; ++i) + try + { + await instanceClient.DreamMaker.Update(new DreamMakerRequest + { + ApiValidationPort = dmPort, + }, cancellationToken); + } + catch (ConflictException ex) when (ex.ErrorCode == ErrorCode.PortNotAvailable) + { + if (i == 4) + throw; + + // I have no idea why this happens sometimes + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + } + + var dmUpdateRequest = UpdateDMSettings(); // need at least one chat bot to satisfy DMAPI test, // use discord as it allows multi-botting on on token unlike IRC @@ -230,7 +247,7 @@ namespace Tgstation.Server.Tests.Live.Instance await Task.WhenAll( jrt.WaitForJob(installJob2.InstallJob, EngineTest.EngineInstallationTimeout(compatVersion) + 30, false, null, cancellationToken), jrt.WaitForJob(cloneRequest.Result.ActiveJob, 60, false, null, cancellationToken), - dmUpdateRequest.AsTask(), + dmUpdateRequest, cloneRequest); if (compatVersion.Engine.Value == EngineType.OpenDream) diff --git a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs index 678ef10fc7..87a04a297d 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs @@ -129,17 +129,34 @@ namespace Tgstation.Server.Tests.Live.Instance } } + async Task UpdateDDSettings() + { + for (var i = 0; i < 5; ++i) + try + { + // Increase startup timeout, disable heartbeats, enable map threads because we've tested without for years + await instanceClient.DreamDaemon.Update(new DreamDaemonRequest + { + StartupTimeout = 60, + HealthCheckSeconds = 0, + Port = ddPort, + MapThreads = 2, + LogOutput = false, + AdditionalParameters = BaseAdditionalParameters + }, cancellationToken); + } + catch (ConflictException ex) when (ex.ErrorCode == ErrorCode.PortNotAvailable) + { + if (i == 4) + throw; + + // I have no idea why this happens sometimes + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + } + await Task.WhenAll( - // Increase startup timeout, disable heartbeats, enable map threads because we've tested without for years - instanceClient.DreamDaemon.Update(new DreamDaemonRequest - { - StartupTimeout = 30, - HealthCheckSeconds = 0, - Port = ddPort, - MapThreads = 2, - LogOutput = false, - AdditionalParameters = BaseAdditionalParameters - }, cancellationToken).AsTask(), + UpdateDDSettings(), CheckByondVersions(), ApiAssert.ThrowsException(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest { diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 9ac95ce865..d745eddbcb 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1342,7 +1342,20 @@ namespace Tgstation.Server.Tests.Live { Api.Models.Instance instance; long initialStaged, initialActive, initialSessionId; + await using var firstAdminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); + await using (var tokenOnlyClient = clientFactory.CreateFromToken(server.RootUrl, firstAdminClient.Token)) + { + // regression test for password change issue + var currentUser = await tokenOnlyClient.Users.Read(cancellationToken); + var updatedUser = await tokenOnlyClient.Users.Update(new UserUpdateRequest + { + Id = currentUser.Id, + Password = DefaultCredentials.DefaultAdminUserPassword, + }, cancellationToken); + + await ApiAssert.ThrowsException(() => tokenOnlyClient.Users.Read(cancellationToken), null); + } async ValueTask CreateUserWithNoInstancePerms() {