From 91f56e5f8526911ef7c4e68aecd31b4921c42ae1 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 3 Sep 2024 23:13:11 -0400 Subject: [PATCH] Add git credentials validation Closes #1876 --- src/Tgstation.Server.Api/Models/ErrorCode.cs | 6 + .../Repository/GitRemoteFeaturesFactory.cs | 14 +- .../Repository/IGitRemoteFeatures.cs | 2 +- .../Repository/IGitRemoteFeaturesFactory.cs | 11 +- .../Controllers/RepositoryController.cs | 116 +++++++++++++++ .../Utils/GitHub/GitHubClientFactory.cs | 132 ++++++++++++------ .../Utils/GitHub/IGitHubClientFactory.cs | 7 + 7 files changed, 235 insertions(+), 53 deletions(-) diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index ca7abbbc8d..cb30e83915 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -657,5 +657,11 @@ namespace Tgstation.Server.Api.Models /// [Description("Could not load configured .dme due to it being outside the deployment directory! This should be a relative path.")] DeploymentWrongDme, + + /// + /// Entered wrong username for a repository access token. + /// + [Description("Provided repository username doesn't match the user of the corresponding access token!")] + RepoTokenUsernameMismatch, } } diff --git a/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesFactory.cs b/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesFactory.cs index 70694d0b94..712ce06d5c 100644 --- a/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesFactory.cs +++ b/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesFactory.cs @@ -45,18 +45,24 @@ namespace Tgstation.Server.Host.Components.Repository public IGitRemoteFeatures CreateGitRemoteFeatures(IRepository repository) { ArgumentNullException.ThrowIfNull(repository); + return CreateGitRemoteFeatures(repository.Origin); + } - var primaryRemote = repository.Origin; - var remoteGitProvider = ParseRemoteGitProviderFromOrigin(primaryRemote); + /// + public IGitRemoteFeatures CreateGitRemoteFeatures(Uri origin) + { + ArgumentNullException.ThrowIfNull(origin); + + var remoteGitProvider = ParseRemoteGitProviderFromOrigin(origin); return remoteGitProvider switch { RemoteGitProvider.GitHub => new GitHubRemoteFeatures( gitHubServiceFactory, loggerFactory.CreateLogger(), - primaryRemote), + origin), RemoteGitProvider.GitLab => new GitLabRemoteFeatures( loggerFactory.CreateLogger(), - primaryRemote), + origin), RemoteGitProvider.Unknown => new DefaultGitRemoteFeatures(), _ => throw new InvalidOperationException($"Unknown RemoteGitProvider: {remoteGitProvider}!"), }; diff --git a/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeatures.cs b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeatures.cs index 0971fdc74d..4f48e8048d 100644 --- a/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeatures.cs +++ b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeatures.cs @@ -3,7 +3,7 @@ /// /// Provides features for remote git services. /// - interface IGitRemoteFeatures : IGitRemoteAdditionalInformation + public interface IGitRemoteFeatures : IGitRemoteAdditionalInformation { /// /// Gets a formatter string which creates the remote refspec for fetching the HEAD of passed in test merge number. diff --git a/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeaturesFactory.cs b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeaturesFactory.cs index 42a55e6285..4ee6ac7a12 100644 --- a/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeaturesFactory.cs +++ b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeaturesFactory.cs @@ -7,15 +7,22 @@ namespace Tgstation.Server.Host.Components.Repository /// /// Factory for creating . /// - interface IGitRemoteFeaturesFactory + public interface IGitRemoteFeaturesFactory { /// /// Create the for a given . /// - /// The to create for. + /// The containing the to create for. /// A new instance. IGitRemoteFeatures CreateGitRemoteFeatures(IRepository repository); + /// + /// Create the for a given . + /// + /// The of the origing URL. + /// A new instance. + IGitRemoteFeatures CreateGitRemoteFeatures(Uri origin); + /// /// Gets the for a given . /// diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 4e7f576fd6..6b4a6f3175 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -2,10 +2,13 @@ using System.Globalization; using System.Linq; using System.Linq.Expressions; +using System.Net; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using GitLabApiClient; + using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -23,6 +26,7 @@ using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Utils; +using Tgstation.Server.Host.Utils.GitHub; namespace Tgstation.Server.Host.Controllers { @@ -43,6 +47,16 @@ namespace Tgstation.Server.Host.Controllers /// readonly IJobManager jobManager; + /// + /// The for the . + /// + readonly IGitRemoteFeaturesFactory gitRemoteFeaturesFactory; + + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + /// /// Initializes a new instance of the class. /// @@ -52,6 +66,8 @@ namespace Tgstation.Server.Host.Controllers /// The for the . /// The value of . /// The value of . + /// The value of . + /// The value of . /// The for the . public RepositoryController( IDatabaseContext databaseContext, @@ -60,6 +76,8 @@ namespace Tgstation.Server.Host.Controllers IInstanceManager instanceManager, ILoggerFactory loggerFactory, IJobManager jobManager, + IGitRemoteFeaturesFactory gitRemoteFeaturesFactory, + IGitHubClientFactory gitHubClientFactory, IApiHeadersProvider apiHeaders) : base( databaseContext, @@ -70,6 +88,8 @@ namespace Tgstation.Server.Host.Controllers { this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); + this.gitRemoteFeaturesFactory = gitRemoteFeaturesFactory ?? throw new ArgumentNullException(nameof(gitRemoteFeaturesFactory)); + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); } /// @@ -109,6 +129,11 @@ namespace Tgstation.Server.Host.Controllers return this.Gone(); currentModel.UpdateSubmodules = model.UpdateSubmodules ?? true; + + var earlyOut = await ValidateCredentials(model, model.Origin, cancellationToken); + if (earlyOut != null) + return earlyOut; + currentModel.AccessToken = model.AccessToken; currentModel.AccessUser = model.AccessUser; @@ -425,6 +450,11 @@ namespace Tgstation.Server.Host.Controllers using var repo = await repoManager.LoadRepository(cancellationToken); if (repo == null) return Conflict(new ErrorMessageResponse(ErrorCode.RepoMissing)); + + var credAuthFailure = await ValidateCredentials(model, repo.Origin, cancellationToken); + if (credAuthFailure != null) + return credAuthFailure; + await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken); return null; @@ -537,5 +567,91 @@ namespace Tgstation.Server.Host.Controllers AuthenticationContext.User, loggerFactory.CreateLogger(), Instance.Require(x => x.Id)); + + /// + /// Validates the of a given if it is set. + /// + /// The to validate. + /// The repository's origin . + /// The for the operation. + /// A resulting in on success, or an on validation failure. + async ValueTask ValidateCredentials(Api.Models.RepositorySettings model, Uri origin, CancellationToken cancellationToken) + { + if (String.IsNullOrWhiteSpace(model.AccessToken)) + return null; + + Logger.LogDebug("Repository access token updated, performing auth check..."); + var remoteFeatures = gitRemoteFeaturesFactory.CreateGitRemoteFeatures(origin); + switch (remoteFeatures.RemoteGitProvider!.Value) + { + case RemoteGitProvider.GitHub: + var gitHubClient = await gitHubClientFactory.CreateClientForRepository( + model.AccessToken, + new RepositoryIdentifier( + remoteFeatures.RemoteRepositoryOwner!, + remoteFeatures.RemoteRepositoryName!), + cancellationToken); + if (gitHubClient == null) + { + return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = "GitHub authentication failed!", + }); + } + + try + { + string username; + if (!model.AccessToken.StartsWith(Api.Models.RepositorySettings.TgsAppPrivateKeyPrefix)) + { + var user = await gitHubClient.User.Current(); + username = user.Login; + } + else + { + // we literally need to app auth again to get the damn bot username + var appClient = gitHubClientFactory.CreateAppClient(model.AccessToken)!; + var app = await appClient.GitHubApps.GetCurrent(); + username = app.Name; + } + + if (username != model.AccessUser) + return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch)); + } + catch (Exception ex) + { + return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = $"GitHub Authentication Failure: {ex.Message}", + }); + } + + break; + case RemoteGitProvider.GitLab: + // need to abstract this eventually + var gitLabClient = new GitLabClient(GitLabRemoteFeatures.GitLabUrl, model.AccessToken); + try + { + var user = await gitLabClient.Users.GetCurrentSessionAsync(); + if (user.Username != model.AccessUser) + return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch)); + } + catch (Exception ex) + { + return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = $"GitLab Authentication Failure: {ex.Message}", + }); + } + + break; + case RemoteGitProvider.Unknown: + default: + Logger.LogWarning("RemoteGitProvider is {provider}, no auth check implemented!", remoteFeatures.RemoteGitProvider.Value); + break; + } + + return null; + } } } diff --git a/src/Tgstation.Server.Host/Utils/GitHub/GitHubClientFactory.cs b/src/Tgstation.Server.Host/Utils/GitHub/GitHubClientFactory.cs index 1ba4d8d523..6a8d9b7a8c 100644 --- a/src/Tgstation.Server.Host/Utils/GitHub/GitHubClientFactory.cs +++ b/src/Tgstation.Server.Host/Utils/GitHub/GitHubClientFactory.cs @@ -98,6 +98,10 @@ namespace Tgstation.Server.Host.Utils.GitHub public ValueTask CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken) => GetOrCreateClient(accessString, repositoryIdentifier, cancellationToken); + /// + public IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey) + => CreateAppClientInternal(tgsEncodedAppPrivateKey ?? throw new ArgumentNullException(nameof(tgsEncodedAppPrivateKey))); + /// /// Retrieve a from the or add a new one based on a given . /// @@ -109,7 +113,7 @@ namespace Tgstation.Server.Host.Utils.GitHub async ValueTask GetOrCreateClient(string? accessString, RepositoryIdentifier? repositoryIdentifier, CancellationToken cancellationToken) #pragma warning restore CA1506 { - GitHubClient client; + GitHubClient? client; bool cacheHit; DateTimeOffset? lastUsed; using (await SemaphoreSlimContext.Lock(clientCacheSemaphore, cancellationToken)) @@ -129,11 +133,6 @@ namespace Tgstation.Server.Host.Utils.GitHub if (!cacheHit) { logger.LogTrace("Creating new GitHubClient..."); - var product = assemblyInformationProvider.ProductInfoHeaderValue.Product!; - client = new GitHubClient( - new ProductHeaderValue( - product.Name, - product.Version)); if (accessString != null) { @@ -143,47 +142,10 @@ namespace Tgstation.Server.Host.Utils.GitHub throw new InvalidOperationException("Cannot create app installation key without target repositoryIdentifier!"); logger.LogTrace("Performing GitHub App authentication for installation on repository {installationRepositoryId}", repositoryIdentifier); - var splits = accessString.Split(':'); - if (splits.Length != 2) - { - logger.LogError("Failed to parse serialized Client ID & PEM! Expected 2 chunks, got {chunkCount}", splits.Length); + + client = CreateAppClientInternal(accessString); + if (client == null) return null; - } - - byte[] pemBytes; - try - { - pemBytes = Convert.FromBase64String(splits[1]); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to parse supposed base64 PEM!"); - return null; - } - - var pem = Encoding.UTF8.GetString(pemBytes); - - using var rsa = RSA.Create(); - rsa.ImportFromPem(pem); - - var signingCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256); - var jwtSecurityTokenHandler = new JwtSecurityTokenHandler { SetDefaultTimesOnTokenCreation = false }; - - var nowDateTime = DateTime.UtcNow; - - var appOrClientId = splits[0][RepositorySettings.TgsAppPrivateKeyPrefix.Length..]; - - var jwt = jwtSecurityTokenHandler.CreateToken(new SecurityTokenDescriptor - { - Issuer = appOrClientId, - Expires = nowDateTime.AddMinutes(10), - IssuedAt = nowDateTime, - SigningCredentials = signingCredentials, - }); - - var jwtStr = jwtSecurityTokenHandler.WriteToken(jwt); - - client.Credentials = new Credentials(jwtStr, AuthenticationType.Bearer); Installation installation; try @@ -213,8 +175,13 @@ namespace Tgstation.Server.Host.Utils.GitHub } } else + { + client = CreateUnauthenticatedClient(); client.Credentials = new Credentials(accessString); + } } + else + client = CreateUnauthenticatedClient(); clientCache.Add(cacheKey, (Client: client, LastUsed: now)); lastUsed = null; @@ -271,5 +238,78 @@ namespace Tgstation.Server.Host.Utils.GitHub return client; } + + /// + /// Create an App (not installation) authenticated . + /// + /// The TGS encoded app private key string. + /// A new app auth for the given on success on failure. + GitHubClient? CreateAppClientInternal(string tgsEncodedAppPrivateKey) + { + var splits = tgsEncodedAppPrivateKey.Split(':'); + if (splits.Length != 2) + { + logger.LogError("Failed to parse serialized Client ID & PEM! Expected 2 chunks, got {chunkCount}", splits.Length); + return null; + } + + byte[] pemBytes; + try + { + pemBytes = Convert.FromBase64String(splits[1]); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse supposed base64 PEM!"); + return null; + } + + var pem = Encoding.UTF8.GetString(pemBytes); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(pem); + + var signingCredentials = new SigningCredentials( + new RsaSecurityKey(rsa), + SecurityAlgorithms.RsaSha256) + { + // https://stackoverflow.com/questions/62307933/rsa-disposed-object-error-every-other-test + CryptoProviderFactory = new CryptoProviderFactory + { + CacheSignatureProviders = false, + }, + }; + var jwtSecurityTokenHandler = new JwtSecurityTokenHandler { SetDefaultTimesOnTokenCreation = false }; + + var nowDateTime = DateTime.UtcNow; + + var appOrClientId = splits[0][RepositorySettings.TgsAppPrivateKeyPrefix.Length..]; + + var jwt = jwtSecurityTokenHandler.CreateToken(new SecurityTokenDescriptor + { + Issuer = appOrClientId, + Expires = nowDateTime.AddMinutes(10), + IssuedAt = nowDateTime, + SigningCredentials = signingCredentials, + }); + + var jwtStr = jwtSecurityTokenHandler.WriteToken(jwt); + var client = CreateUnauthenticatedClient(); + client.Credentials = new Credentials(jwtStr, AuthenticationType.Bearer); + return client; + } + + /// + /// Creates an unauthenticated . + /// + /// A new . + GitHubClient CreateUnauthenticatedClient() + { + var product = assemblyInformationProvider.ProductInfoHeaderValue.Product!; + return new GitHubClient( + new ProductHeaderValue( + product.Name, + product.Version)); + } } } diff --git a/src/Tgstation.Server.Host/Utils/GitHub/IGitHubClientFactory.cs b/src/Tgstation.Server.Host/Utils/GitHub/IGitHubClientFactory.cs index 944bcb0cf9..be630c152f 100644 --- a/src/Tgstation.Server.Host/Utils/GitHub/IGitHubClientFactory.cs +++ b/src/Tgstation.Server.Host/Utils/GitHub/IGitHubClientFactory.cs @@ -33,5 +33,12 @@ namespace Tgstation.Server.Host.Utils.GitHub /// The for the operation. /// A resulting in a new for the given or if authentication failed. ValueTask CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken); + + /// + /// Create an App (not installation) authenticated . + /// + /// The TGS encoded app private key string. + /// A new app auth for the given on success on failure. + IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey); } }