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);
}
}