From af370febcaf7a2f4a4b05180def398406446e0d7 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 2 Nov 2024 08:52:14 -0400 Subject: [PATCH 1/7] Flesh out graph structure some more --- src/Tgstation.Server.Host/GraphQL/Types/Instance.cs | 7 +++++++ .../GraphQL/Types/InstancePermissionSet.cs | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs index 192a5b377b..26223a455e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs @@ -14,5 +14,12 @@ public sealed class Instance : Entity /// Queryable s. public IQueryable QueryableInstancePermissionSets() => throw new NotImplementedException(); + + /// + /// Gets the callers effective on the . + /// + /// The callers effective if it exists or . + public InstancePermissionSet? EffectivePermissionSet() + => throw new NotImplementedException(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs index 76bcabcac6..ebe30ac072 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs @@ -1,4 +1,6 @@ -using Tgstation.Server.Api.Rights; +using System; + +using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.GraphQL.Types { @@ -7,6 +9,13 @@ namespace Tgstation.Server.Host.GraphQL.Types /// public sealed class InstancePermissionSet { + /// + /// Gets the the belongs to. + /// + /// The owning . + public PermissionSet PermissionSet() + => throw new NotImplementedException(); + /// /// The of the . /// From 266e9867d8a3209be16fca30c5c567390267629e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 2 Nov 2024 11:57:16 -0400 Subject: [PATCH 2/7] Setup OAuth Gateways Closes #1996 --- README.md | 7 ++- build/Version.props | 12 ++--- .../Models/OAuthProviderInfo.cs | 5 ++ .../Models/Response/OAuthGatewayResponse.cs | 13 +++++ .../Authority/ILoginAuthority.cs | 11 +++- .../Authority/LoginAuthority.cs | 51 +++++++++++++++---- .../Configuration/OAuthConfigurationBase.cs | 6 +++ .../Configuration/OAuthGatewayStatus.cs | 23 +++++++++ .../Controllers/ApiRootController.cs | 12 +++++ .../OAuthGatewayStatusExtensions.cs | 24 +++++++++ src/Tgstation.Server.Host/GraphQL/Mutation.cs | 21 +++++++- .../GraphQL/Mutations/Payloads/LoginResult.cs | 2 +- .../Payloads/OAuthGatewayLoginResult.cs | 26 ++++++++++ .../Types/OAuth/BasicOAuthProviderInfo.cs | 6 +++ .../Security/OAuth/GenericOAuthValidator.cs | 12 ++++- .../Security/OAuth/GitHubOAuthValidator.cs | 12 ++++- .../Security/OAuth/IOAuthProviders.cs | 3 +- .../Security/OAuth/IOAuthValidator.cs | 10 +++- .../Security/OAuth/OAuthProviders.cs | 5 +- src/Tgstation.Server.Host/appsettings.yml | 1 + 20 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs create mode 100644 src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs create mode 100644 src/Tgstation.Server.Host/Extensions/OAuthGatewayStatusExtensions.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/OAuthGatewayLoginResult.cs diff --git a/README.md b/README.md index a03445fd6b..24077a5a10 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will - `Swarm:UpdateRequiredNodeCount`: Should be set to the total number of servers in your swarm minus 1. Prevents updates from occurring unless the non-controller server count in the swarm is greater than or equal to this value. -- `Security:OAuth:`: Sets the OAuth client ID and secret for a given ``. The currently supported providers are `Keycloak`, `GitHub`, `Discord`, `InvisionCommunity` and `TGForums`. Setting these fields to `null` disables logins with the provider, but does not stop users from associating their accounts using the API. Sample Entry: +- `Security:OAuth:`: Sets the OAuth client ID and secret for a given ``. The currently supported providers are `Keycloak`, `GitHub`, `Discord`, `InvisionCommunity` and `TGForums`. Setting these fields to `null` disables logins AND gateway auth with the provider, but does not stop users from associating their accounts using the API. Sample Entry: ```yml Security: OAuth: @@ -301,6 +301,7 @@ Security: ClientSecret: "..." RedirectUrl: "..." ServerUrl: "..." + Gateway: Disabled # Can be one of `Disabled` disallowing gateway auth (default), `Enabled` allowing gateway auth, or `Only` allowing gateway auth and disabling OAuth logins with this provider UserInformationUrlOverride: "..." # For power users, leave out of configuration for most cases. Not supported by GitHub provider. ``` The following providers use the `RedirectUrl` setting: @@ -315,6 +316,8 @@ The following providers use the `ServerUrl` setting: - Keycloak - InvisionCommunity +Gateway auth simply allows the users to authenticate with the service using the configuration you provide and have their impersonation token passed back to the client. An example of this is using GitHub gateway auth to allow clients to enumerate pull requests without getting rate limited. + - `Telemetry:DisableVersionReporting`: Prevents you installation and the version you're using from being reported on the source repository's deployments list - `Telemetry:ServerFriendlyName`: Prevents anonymous TGS version usage statistics from being sent to be displayed on the repository. @@ -637,7 +640,7 @@ This functionality has the following prerequisites: Here are tools for interacting with the TGS web API - [tgstation-server-webpanel](https://github.com/tgstation/tgstation-server-webpanel): Official client and included with the server. A react web app for using tgstation-server. -- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Official client. A cross platform GUI for using tgstation-server. Feature complete but lacks OAuth login options. +- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Deprecated client. A cross platform GUI for using tgstation-server. - [Tgstation.Server.Client](https://www.nuget.org/packages/Tgstation.Server.Client): A nuget .NET Standard 2.0 TAP based library for communicating with tgstation-server. Feature complete. - [Tgstation.Server.Api](https://www.nuget.org/packages/Tgstation.Server.Api): A nuget .NET Standard 2.0 library containing API definitions for tgstation-server. Feature complete. diff --git a/build/Version.props b/build/Version.props index a6ac88d9e4..4d4dcc7393 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,13 +3,13 @@ - 6.11.4 - 5.3.0 - 10.10.0 - 0.4.0 + 6.12.0 + 5.4.0 + 10.11.0 + 0.5.0 7.0.0 - 16.1.0 - 19.1.0 + 16.2.0 + 19.2.0 7.3.0 5.10.0 1.5.0 diff --git a/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs b/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs index dd34dbe536..6a45bbc6fc 100644 --- a/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs +++ b/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs @@ -22,5 +22,10 @@ public sealed class OAuthProviderInfo /// [ResponseOptions] public Uri? ServerUrl { get; set; } + + /// + /// If the OAuth provider may only be used for gateway authentication. If the OAuth provider may be used for server logins or gateway authentication. If the OAuth provider may only be used for server logins. + /// + public bool? GatewayOnly { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs b/src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs new file mode 100644 index 0000000000..0d8b5775d7 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Api.Models.Response +{ + /// + /// Success result for an OAuth gateway login attempt. + /// + public sealed class OAuthGatewayResponse + { + /// + /// The user's access token for the requested OAuth service. + /// + public string? AccessCode { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs index b55e94be64..0558bbfaa0 100644 --- a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -12,10 +12,17 @@ namespace Tgstation.Server.Host.Authority public interface ILoginAuthority : IAuthority { /// - /// Attempt to login to the server with the current crentials. + /// Attempt to login to the server with the current Basic or OAuth credentials. /// /// The for the operation. - /// A resulting in a and . + /// A resulting in a . ValueTask> AttemptLogin(CancellationToken cancellationToken); + + /// + /// Attempt to login to an OAuth service with the current OAuth credentials. + /// + /// The for the operation. + /// A resulting in an . + ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index 9e0602d07e..e0ae9ab433 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -61,9 +61,10 @@ sealed class LoginAuthority : AuthorityBase, ILoginAuthority /// /// Generate an for a given . /// + /// The of to generate. /// The to generate a response for. /// A new, errored . - static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) + static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) => new( new ErrorMessageResponse(ErrorCode.BadHeaders) { @@ -135,7 +136,7 @@ public async ValueTask> AttemptLogin(Cancellation { var headers = apiHeadersProvider.ApiHeaders; if (headers == null) - return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); if (headers.IsTokenAuthentication) return BadRequest(ErrorCode.TokenWithToken); @@ -161,32 +162,32 @@ public async ValueTask> AttemptLogin(Cancellation if (oAuthLogin) { var oAuthProvider = headers.OAuthProvider!.Value; - string? externalUserId; + (string? UserID, string AccessCode)? oauthResult; try { var validator = oAuthProviders - .GetValidator(oAuthProvider); + .GetValidator(oAuthProvider, true); if (validator == null) return BadRequest(ErrorCode.OAuthProviderDisabled); - externalUserId = await validator - .ValidateResponseCode(headers.OAuthCode!, cancellationToken); + oauthResult = await validator + .ValidateResponseCode(headers.OAuthCode!, true, cancellationToken); - Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); + Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, oauthResult); } catch (Octokit.RateLimitExceededException ex) { return RateLimit(ex); } - if (externalUserId == null) + if (!oauthResult.HasValue) return Unauthorized(); query = query.Where( x => x.OAuthConnections!.Any( y => y.Provider == oAuthProvider - && y.ExternalUserId == externalUserId)); + && y.ExternalUserId == oauthResult.Value.UserID)); } else { @@ -281,6 +282,38 @@ public async ValueTask> AttemptLogin(Cancellation } } + /// + public async ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken) + { + var headers = apiHeadersProvider.ApiHeaders; + if (headers == null) + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + + var oAuthProvider = headers.OAuthProvider; + if (!oAuthProvider.HasValue) + return BadRequest(ErrorCode.BadHeaders); + + var validator = oAuthProviders + .GetValidator(oAuthProvider.Value, false); + + if (validator == null) + return BadRequest(ErrorCode.OAuthProviderDisabled); + + var result = await validator + .ValidateResponseCode(headers.OAuthCode!, false, cancellationToken); + + if (!result.HasValue) + return Unauthorized(); + + Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value); + + return new AuthorityResponse( + new OAuthGatewayLoginResult + { + AccessCode = result.Value.AccessCode, + }); + } + /// /// Add a given to the . /// diff --git a/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs b/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs index a170cd235f..1236b69834 100644 --- a/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs +++ b/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs @@ -17,6 +17,11 @@ public abstract class OAuthConfigurationBase /// public string? ClientSecret { get; set; } + /// + /// If the OAuth setup is only to be used for passing the user's OAuth token to clients. + /// + public OAuthGatewayStatus? Gateway { get; set; } + /// /// Initializes a new instance of the class. /// @@ -33,6 +38,7 @@ public OAuthConfigurationBase(OAuthConfigurationBase oAuthConfiguration) ArgumentNullException.ThrowIfNull(oAuthConfiguration); ClientId = oAuthConfiguration.ClientId; ClientSecret = oAuthConfiguration.ClientSecret; + Gateway = oAuthConfiguration.Gateway; } } } diff --git a/src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs b/src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs new file mode 100644 index 0000000000..8abf7d4afc --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host +{ + /// + /// Status of the OAuth gateway for a provider. + /// + public enum OAuthGatewayStatus + { + /// + /// The OAuth Gateway is disabled. + /// + Disabled, + + /// + /// The OAuth Gateway is enabled. + /// + Enabled, + + /// + /// The provider may ONLY be used as an OAuth Gateway. + /// + Only, + } +} diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 433ec5716f..652c98d445 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -188,5 +188,17 @@ public ValueTask CreateToken(CancellationToken cancellationToken) return loginAuthority.InvokeTransformable(this, authority => authority.AttemptLogin(cancellationToken)); } + + /// + /// Attempt to authenticate a using . + /// + /// The for the operation. + /// A resulting in the of the operation. + /// generated successfully. + /// OAuth authentication failed. + [HttpPost("oauth_gateway")] + [ProducesResponseType(typeof(OAuthGatewayResponse), 200)] + public ValueTask CreateOAuthGatewayToken(CancellationToken cancellationToken) + => loginAuthority.InvokeTransformable(this, authority => authority.AttemptOAuthGatewayLogin(cancellationToken)); } } diff --git a/src/Tgstation.Server.Host/Extensions/OAuthGatewayStatusExtensions.cs b/src/Tgstation.Server.Host/Extensions/OAuthGatewayStatusExtensions.cs new file mode 100644 index 0000000000..0faad835b2 --- /dev/null +++ b/src/Tgstation.Server.Host/Extensions/OAuthGatewayStatusExtensions.cs @@ -0,0 +1,24 @@ +using System; + +namespace Tgstation.Server.Host.Extensions +{ + /// + /// Extension methods for . + /// + static class OAuthGatewayStatusExtensions + { + /// + /// Convert a given to a for API usage. + /// + /// The to convert. + /// The form of the . + public static bool? ToBoolean(this OAuthGatewayStatus oAuthGatewayStatus) + => oAuthGatewayStatus switch + { + OAuthGatewayStatus.Disabled => null, + OAuthGatewayStatus.Enabled => false, + OAuthGatewayStatus.Only => true, + _ => throw new InvalidOperationException($"Invalid {nameof(OAuthGatewayStatus)}: {oAuthGatewayStatus}"), + }; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index c30790791c..286fe00619 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -23,11 +23,11 @@ public sealed class Mutation public const string GraphQLDescription = "Root Mutation type"; /// - /// Generate a JWT for authenticating with server. This is the only operation that accepts and required basic authentication. + /// Generate a JWT for authenticating with server. This requires either the Basic authentication or OAuth authentication schemes. /// /// The for the . /// The for the operation. - /// A Bearer token to be used with further communication with the server. + /// A . [Error(typeof(ErrorMessageException))] public ValueTask Login( [Service] IGraphQLAuthorityInvoker loginAuthority, @@ -38,5 +38,22 @@ public ValueTask Login( return loginAuthority.Invoke( authority => authority.AttemptLogin(cancellationToken)); } + + /// + /// Generate an OAuth user token for the requested service. This requires the OAuth authentication scheme. + /// + /// The for the . + /// The for the operation. + /// An . + [Error(typeof(ErrorMessageException))] + public ValueTask OAuthGateway( + [Service] IGraphQLAuthorityInvoker loginAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(loginAuthority); + + return loginAuthority.Invoke( + authority => authority.AttemptOAuthGatewayLogin(cancellationToken)); + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs index cf30206115..7694d59202 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads public sealed class LoginResult : ILegacyApiTransformable { /// - /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time. + /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server at non-login and non-transfer endpoints. Contains an expiry time. /// [GraphQLType] [GraphQLNonNullType] diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/OAuthGatewayLoginResult.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/OAuthGatewayLoginResult.cs new file mode 100644 index 0000000000..12b369cc95 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/OAuthGatewayLoginResult.cs @@ -0,0 +1,26 @@ +using HotChocolate; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads +{ + /// + /// Success result for an OAuth gateway login attempt. + /// + public sealed class OAuthGatewayLoginResult : ILegacyApiTransformable + { + /// + /// The user's access token for the requested OAuth service. + /// + public required string AccessCode { get; init; } + + /// + [GraphQLIgnore] + public OAuthGatewayResponse ToApi() + => new() + { + AccessCode = AccessCode, + }; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs index f503d7adc8..3eac589012 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs @@ -14,6 +14,11 @@ public class BasicOAuthProviderInfo /// public string ClientID { get; } + /// + /// If the OAuth provider can only be used for gateway authentication. If the OAuth provider may be used for server logins or gateway authentication. If the OAuth provider may only be used for server logins. + /// + public bool? GatewayOnly { get; } + /// /// Initializes a new instance of the class. /// @@ -23,6 +28,7 @@ public BasicOAuthProviderInfo(OAuthProviderInfo providerInfo) ArgumentNullException.ThrowIfNull(providerInfo); ClientID = providerInfo.ClientId ?? throw new InvalidOperationException("ClientID not set!"); + GatewayOnly = providerInfo.GatewayOnly; } } } diff --git a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs index 5963023b0f..d14c461a4d 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs @@ -15,6 +15,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Extensions; namespace Tgstation.Server.Host.Security.OAuth { @@ -26,6 +27,9 @@ abstract class GenericOAuthValidator : IOAuthValidator /// public abstract OAuthProvider Provider { get; } + /// + public OAuthGatewayStatus GatewayStatus => OAuthConfiguration.Gateway!.Value; + /// /// The for the . /// @@ -80,7 +84,7 @@ public GenericOAuthValidator( } /// - public async ValueTask ValidateResponseCode(string code, CancellationToken cancellationToken) + public async ValueTask<(string? UserID, string AccessCode)?> ValidateResponseCode(string code, bool requireUserID, CancellationToken cancellationToken) { using var httpClient = CreateHttpClient(); string? tokenResponsePayload = null; @@ -112,6 +116,9 @@ public GenericOAuthValidator( return null; } + if (!requireUserID) + return (null, AccessCode: accessToken); + Logger.LogTrace("Getting user details..."); var userInfoUrl = OAuthConfiguration?.UserInformationUrlOverride ?? UserInformationUrl; @@ -126,7 +133,7 @@ public GenericOAuthValidator( var userInformationJson = JObject.Parse(userInformationPayload); - return DecodeUserInformationPayload(userInformationJson); + return (DecodeUserInformationPayload(userInformationJson), AccessCode: accessToken); } catch (Exception ex) { @@ -146,6 +153,7 @@ public OAuthProviderInfo GetProviderInfo() ClientId = OAuthConfiguration.ClientId, RedirectUri = OAuthConfiguration.RedirectUrl, ServerUrl = OAuthConfiguration.ServerUrl, + GatewayOnly = GatewayStatus.ToBoolean(), }; /// diff --git a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs index d535d97884..e07ff4b7ed 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs @@ -8,6 +8,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Utils.GitHub; namespace Tgstation.Server.Host.Security.OAuth @@ -20,6 +21,9 @@ sealed class GitHubOAuthValidator : IOAuthValidator /// public OAuthProvider Provider => OAuthProvider.GitHub; + /// + public OAuthGatewayStatus GatewayStatus => oAuthConfiguration.Gateway!.Value; + /// /// The for the . /// @@ -52,7 +56,7 @@ public GitHubOAuthValidator( } /// - public async ValueTask ValidateResponseCode(string code, CancellationToken cancellationToken) + public async ValueTask<(string? UserID, string AccessCode)?> ValidateResponseCode(string code, bool requireUserID, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(code); @@ -65,12 +69,15 @@ public GitHubOAuthValidator( if (token == null) return null; + if (!requireUserID) + return (null, AccessCode: token); + var authenticatedClient = await gitHubServiceFactory.CreateService(token, cancellationToken); logger.LogTrace("Getting user details..."); var userId = await authenticatedClient.GetCurrentUserId(cancellationToken); - return userId.ToString(CultureInfo.InvariantCulture); + return (userId.ToString(CultureInfo.InvariantCulture), AccessCode: token); } catch (RateLimitExceededException) { @@ -89,6 +96,7 @@ public OAuthProviderInfo GetProviderInfo() { ClientId = oAuthConfiguration.ClientId, RedirectUri = oAuthConfiguration.RedirectUrl, + GatewayOnly = GatewayStatus.ToBoolean(), }; } } diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs index 2da6651c04..3b72cd45be 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs @@ -13,8 +13,9 @@ public interface IOAuthProviders /// Gets the for a given . /// /// The to get the validator for. + /// If the resulting will be used to authenticate a server login. /// The for . - IOAuthValidator? GetValidator(OAuthProvider oAuthProvider); + IOAuthValidator? GetValidator(OAuthProvider oAuthProvider, bool forLogin); /// /// Gets a of the provider client IDs. diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs index 472a7ff091..95f51e354d 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs @@ -15,6 +15,11 @@ public interface IOAuthValidator /// OAuthProvider Provider { get; } + /// + /// The for the . + /// + OAuthGatewayStatus GatewayStatus { get; } + /// /// Gets the of validator. /// @@ -25,8 +30,9 @@ public interface IOAuthValidator /// Validate a given OAuth response . /// /// The OAuth response string from web application. + /// If the resulting user ID should be retrieved. /// The for the operation. - /// A resulting in if authentication failed, if a rate limit occurred, and the validated otherwise. - ValueTask ValidateResponseCode(string code, CancellationToken cancellationToken); + /// A resulting in if authentication failed or the validated and OAuth access code otherwise. + ValueTask<(string? UserID, string AccessCode)?> ValidateResponseCode(string code, bool requireUserID, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs index 9d28ab1c82..5b5af32ba9 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs @@ -80,7 +80,10 @@ public OAuthProviders( } /// - public IOAuthValidator? GetValidator(OAuthProvider oAuthProvider) => validators.FirstOrDefault(x => x.Provider == oAuthProvider); + public IOAuthValidator? GetValidator(OAuthProvider oAuthProvider, bool forLogin) + => validators.FirstOrDefault( + x => x.Provider == oAuthProvider + && ((forLogin && x.GatewayStatus != OAuthGatewayStatus.Only) || (!forLogin && x.GatewayStatus != OAuthGatewayStatus.Disabled))); /// public Dictionary ProviderInfos() diff --git a/src/Tgstation.Server.Host/appsettings.yml b/src/Tgstation.Server.Host/appsettings.yml index b965a326ed..ac08b78c49 100644 --- a/src/Tgstation.Server.Host/appsettings.yml +++ b/src/Tgstation.Server.Host/appsettings.yml @@ -65,6 +65,7 @@ Security: # ClientId: # OAuth client ID # ClientSecret: # OAuth client secret # ServerUrl: # Only used by Keycloak and InvisionCommunity. Server URL (Includes Keycloak realm) +# Gateway: # Can be one of `Disabled` disallowing gateway auth (default), `Enabled` allowing gateway auth, or `Only` allowing gateway auth and disabling OAuth logins with this provider # UserInformationUrlOverride: # Not supported by GitHub. Overrides the URL TGS uses to retrieve a user's information GitHub: # https://github.com OAuth configuration Discord: # https://discord.com OAuth configuration From d783b2105d5ec843cb58cb7f4219281f81d7d312 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 2 Nov 2024 12:10:09 -0400 Subject: [PATCH 3/7] Correct GraphQL doc comment --- .../GraphQL/Mutations/Payloads/LoginResult.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs index 7694d59202..a28589c21d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads public sealed class LoginResult : ILegacyApiTransformable { /// - /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server at non-login and non-transfer endpoints. Contains an expiry time. + /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server at non-login endpoints. Contains an expiry time. /// [GraphQLType] [GraphQLNonNullType] From 044021ee2980fd134c24f032e3cfabd3a8cdcda1 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 2 Nov 2024 12:32:53 -0400 Subject: [PATCH 4/7] Fix OAuth Gateway missing rate limit response definition --- src/Tgstation.Server.Host/Controllers/ApiRootController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 652c98d445..49f3e89c2b 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -196,8 +196,10 @@ public ValueTask CreateToken(CancellationToken cancellationToken) /// A resulting in the of the operation. /// generated successfully. /// OAuth authentication failed. + /// OAuth authentication failed due to rate limiting. [HttpPost("oauth_gateway")] [ProducesResponseType(typeof(OAuthGatewayResponse), 200)] + [ProducesResponseType(typeof(ErrorMessageResponse), 429)] public ValueTask CreateOAuthGatewayToken(CancellationToken cancellationToken) => loginAuthority.InvokeTransformable(this, authority => authority.AttemptOAuthGatewayLogin(cancellationToken)); } From ff0b1ce87d61ca48c7f1608638e7150afe2cea72 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 2 Nov 2024 12:48:23 -0400 Subject: [PATCH 5/7] Fix complexity warning in `LoginAuthority` --- .../Authority/LoginAuthority.cs | 77 +++++++++++-------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index e0ae9ab433..c98863d01c 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -162,32 +162,14 @@ public async ValueTask> AttemptLogin(Cancellation if (oAuthLogin) { var oAuthProvider = headers.OAuthProvider!.Value; - (string? UserID, string AccessCode)? oauthResult; - try - { - var validator = oAuthProviders - .GetValidator(oAuthProvider, true); - - if (validator == null) - return BadRequest(ErrorCode.OAuthProviderDisabled); - - oauthResult = await validator - .ValidateResponseCode(headers.OAuthCode!, true, cancellationToken); - - Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, oauthResult); - } - catch (Octokit.RateLimitExceededException ex) - { - return RateLimit(ex); - } - - if (!oauthResult.HasValue) - return Unauthorized(); + var (errorResponse, oauthResult) = await TryOAuthenticate(headers, oAuthProvider, true, cancellationToken); + if (errorResponse != null) + return errorResponse; query = query.Where( x => x.OAuthConnections!.Any( y => y.Provider == oAuthProvider - && y.ExternalUserId == oauthResult.Value.UserID)); + && y.ExternalUserId == oauthResult!.Value.UserID)); } else { @@ -293,24 +275,16 @@ public async ValueTask> AttemptOAuthG if (!oAuthProvider.HasValue) return BadRequest(ErrorCode.BadHeaders); - var validator = oAuthProviders - .GetValidator(oAuthProvider.Value, false); - - if (validator == null) - return BadRequest(ErrorCode.OAuthProviderDisabled); - - var result = await validator - .ValidateResponseCode(headers.OAuthCode!, false, cancellationToken); - - if (!result.HasValue) - return Unauthorized(); + var (errorResponse, oAuthResult) = await TryOAuthenticate(headers, oAuthProvider.Value, false, cancellationToken); + if (errorResponse != null) + return errorResponse; Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value); return new AuthorityResponse( new OAuthGatewayLoginResult { - AccessCode = result.Value.AccessCode, + AccessCode = oAuthResult!.Value.AccessCode, }); } @@ -329,5 +303,40 @@ private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User identExpiry += TimeSpan.FromSeconds(15); await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry); } + + /// + /// Attempt OAuth authentication. + /// + /// The to use for errored s. + /// The current . + /// The to use. + /// If this is for a server login. + /// The for the operation. + /// A resulting in an errored on failure or the result of the call to on success. + async ValueTask<(AuthorityResponse? ErrorResponse, (string? UserID, string AccessCode)? OAuthResult)> TryOAuthenticate(ApiHeaders headers, OAuthProvider oAuthProvider, bool forLogin, CancellationToken cancellationToken) + { + (string? UserID, string AccessCode)? oauthResult; + try + { + var validator = oAuthProviders + .GetValidator(oAuthProvider, forLogin); + + if (validator == null) + return (BadRequest(ErrorCode.OAuthProviderDisabled), null); + oauthResult = await validator + .ValidateResponseCode(headers.OAuthCode!, forLogin, cancellationToken); + + Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, oauthResult); + } + catch (Octokit.RateLimitExceededException ex) + { + return (RateLimit(ex), null); + } + + if (!oauthResult.HasValue) + return (Unauthorized(), null); + + return (null, OAuthResult: oauthResult); + } } } From 362913db3c1bfc039a8dc186e64fa406f20a92c2 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 2 Nov 2024 13:09:57 -0400 Subject: [PATCH 6/7] Fix null reference exceptions --- .../Security/OAuth/GenericOAuthValidator.cs | 2 +- .../Security/OAuth/GitHubOAuthValidator.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs index d14c461a4d..8c0d1b8b02 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs @@ -28,7 +28,7 @@ abstract class GenericOAuthValidator : IOAuthValidator public abstract OAuthProvider Provider { get; } /// - public OAuthGatewayStatus GatewayStatus => OAuthConfiguration.Gateway!.Value; + public OAuthGatewayStatus GatewayStatus => OAuthConfiguration.Gateway ?? OAuthGatewayStatus.Disabled; /// /// The for the . diff --git a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs index e07ff4b7ed..5a967912b9 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs @@ -22,7 +22,7 @@ sealed class GitHubOAuthValidator : IOAuthValidator public OAuthProvider Provider => OAuthProvider.GitHub; /// - public OAuthGatewayStatus GatewayStatus => oAuthConfiguration.Gateway!.Value; + public OAuthGatewayStatus GatewayStatus => oAuthConfiguration.Gateway ?? OAuthGatewayStatus.Disabled; /// /// The for the . From d6a8e102e6eaf2bdad03f4bafb5d05c57b5430ff Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 2 Nov 2024 13:10:38 -0400 Subject: [PATCH 7/7] Use default --- .../Security/OAuth/GenericOAuthValidator.cs | 2 +- .../Security/OAuth/GitHubOAuthValidator.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs index 8c0d1b8b02..b68813be3c 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs @@ -28,7 +28,7 @@ abstract class GenericOAuthValidator : IOAuthValidator public abstract OAuthProvider Provider { get; } /// - public OAuthGatewayStatus GatewayStatus => OAuthConfiguration.Gateway ?? OAuthGatewayStatus.Disabled; + public OAuthGatewayStatus GatewayStatus => OAuthConfiguration.Gateway ?? default; /// /// The for the . diff --git a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs index 5a967912b9..3bb7164f63 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs @@ -22,7 +22,7 @@ sealed class GitHubOAuthValidator : IOAuthValidator public OAuthProvider Provider => OAuthProvider.GitHub; /// - public OAuthGatewayStatus GatewayStatus => oAuthConfiguration.Gateway ?? OAuthGatewayStatus.Disabled; + public OAuthGatewayStatus GatewayStatus => oAuthConfiguration.Gateway ?? default; /// /// The for the .