mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-22 12:37:24 +01:00
@@ -657,5 +657,11 @@ namespace Tgstation.Server.Api.Models
|
||||
/// </summary>
|
||||
[Description("Could not load configured .dme due to it being outside the deployment directory! This should be a relative path.")]
|
||||
DeploymentWrongDme,
|
||||
|
||||
/// <summary>
|
||||
/// Entered wrong username for a repository access token.
|
||||
/// </summary>
|
||||
[Description("Provided repository username doesn't match the user of the corresponding access token!")]
|
||||
RepoTokenUsernameMismatch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
/// <inheritdoc />
|
||||
public IGitRemoteFeatures CreateGitRemoteFeatures(Uri origin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origin);
|
||||
|
||||
var remoteGitProvider = ParseRemoteGitProviderFromOrigin(origin);
|
||||
return remoteGitProvider switch
|
||||
{
|
||||
RemoteGitProvider.GitHub => new GitHubRemoteFeatures(
|
||||
gitHubServiceFactory,
|
||||
loggerFactory.CreateLogger<GitHubRemoteFeatures>(),
|
||||
primaryRemote),
|
||||
origin),
|
||||
RemoteGitProvider.GitLab => new GitLabRemoteFeatures(
|
||||
loggerFactory.CreateLogger<GitLabRemoteFeatures>(),
|
||||
primaryRemote),
|
||||
origin),
|
||||
RemoteGitProvider.Unknown => new DefaultGitRemoteFeatures(),
|
||||
_ => throw new InvalidOperationException($"Unknown RemoteGitProvider: {remoteGitProvider}!"),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/// <summary>
|
||||
/// Provides features for remote git services.
|
||||
/// </summary>
|
||||
interface IGitRemoteFeatures : IGitRemoteAdditionalInformation
|
||||
public interface IGitRemoteFeatures : IGitRemoteAdditionalInformation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a formatter string which creates the remote refspec for fetching the HEAD of passed in test merge number.
|
||||
|
||||
@@ -7,15 +7,22 @@ namespace Tgstation.Server.Host.Components.Repository
|
||||
/// <summary>
|
||||
/// Factory for creating <see cref="IGitRemoteFeatures"/>.
|
||||
/// </summary>
|
||||
interface IGitRemoteFeaturesFactory
|
||||
public interface IGitRemoteFeaturesFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create the <see cref="IGitRemoteFeatures"/> for a given <paramref name="repository"/>.
|
||||
/// </summary>
|
||||
/// <param name="repository">The <see cref="IRepository"/> to create <see cref="IGitRemoteFeatures"/> for.</param>
|
||||
/// <param name="repository">The <see cref="IRepository"/> containing the <see cref="IRepository.Origin"/> to create <see cref="IGitRemoteFeatures"/> for.</param>
|
||||
/// <returns>A new <see cref="IGitRemoteFeatures"/> instance.</returns>
|
||||
IGitRemoteFeatures CreateGitRemoteFeatures(IRepository repository);
|
||||
|
||||
/// <summary>
|
||||
/// Create the <see cref="IGitRemoteFeatures"/> for a given <paramref name="origin"/>.
|
||||
/// </summary>
|
||||
/// <param name="origin">The <see cref="Uri"/> of the <see cref="IGitRemoteFeatures"/> origing URL.</param>
|
||||
/// <returns>A new <see cref="IGitRemoteFeatures"/> instance.</returns>
|
||||
IGitRemoteFeatures CreateGitRemoteFeatures(Uri origin);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="RemoteGitProvider"/> for a given <paramref name="origin"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
readonly IJobManager jobManager;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IGitRemoteFeaturesFactory"/> for the <see cref="RepositoryController"/>.
|
||||
/// </summary>
|
||||
readonly IGitRemoteFeaturesFactory gitRemoteFeaturesFactory;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IGitHubClientFactory"/> for the <see cref="RepositoryController"/>.
|
||||
/// </summary>
|
||||
readonly IGitHubClientFactory gitHubClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RepositoryController"/> class.
|
||||
/// </summary>
|
||||
@@ -52,6 +66,8 @@ namespace Tgstation.Server.Host.Controllers
|
||||
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
|
||||
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/>.</param>
|
||||
/// <param name="jobManager">The value of <see cref="jobManager"/>.</param>
|
||||
/// <param name="gitRemoteFeaturesFactory">The value of <see cref="gitRemoteFeaturesFactory"/>.</param>
|
||||
/// <param name="gitHubClientFactory">The value of <see cref="gitHubClientFactory"/>.</param>
|
||||
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<RepositoryUpdateService>(),
|
||||
Instance.Require(x => x.Id));
|
||||
|
||||
/// <summary>
|
||||
/// Validates the <see cref="Api.Models.RepositorySettings.AccessToken"/> of a given <paramref name="model"/> if it is set.
|
||||
/// </summary>
|
||||
/// <param name="model">The <see cref="Api.Models.RepositorySettings"/> to validate.</param>
|
||||
/// <param name="origin">The repository's origin <see cref="Uri"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in <see langword="null"/> on success, or an <see cref="IActionResult"/> on validation failure.</returns>
|
||||
async ValueTask<IActionResult?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ namespace Tgstation.Server.Host.Utils.GitHub
|
||||
public ValueTask<IGitHubClient?> CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken)
|
||||
=> GetOrCreateClient(accessString, repositoryIdentifier, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey)
|
||||
=> CreateAppClientInternal(tgsEncodedAppPrivateKey ?? throw new ArgumentNullException(nameof(tgsEncodedAppPrivateKey)));
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a <see cref="GitHubClient"/> from the <see cref="clientCache"/> or add a new one based on a given <paramref name="accessString"/>.
|
||||
/// </summary>
|
||||
@@ -109,7 +113,7 @@ namespace Tgstation.Server.Host.Utils.GitHub
|
||||
async ValueTask<IGitHubClient?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an App (not installation) authenticated <see cref="GitHubClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="tgsEncodedAppPrivateKey">The TGS encoded app private key string.</param>
|
||||
/// <returns>A new app auth <see cref="GitHubClient"/> for the given <paramref name="tgsEncodedAppPrivateKey"/> on success <see langword="null"/> on failure.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unauthenticated <see cref="GitHubClient"/>.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="GitHubClient"/>.</returns>
|
||||
GitHubClient CreateUnauthenticatedClient()
|
||||
{
|
||||
var product = assemblyInformationProvider.ProductInfoHeaderValue.Product!;
|
||||
return new GitHubClient(
|
||||
new ProductHeaderValue(
|
||||
product.Name,
|
||||
product.Version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,12 @@ namespace Tgstation.Server.Host.Utils.GitHub
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IGitHubClient"/> for the given <paramref name="repositoryIdentifier"/> or <see langword="null"/> if authentication failed.</returns>
|
||||
ValueTask<IGitHubClient?> CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Create an App (not installation) authenticated <see cref="IGitHubClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="tgsEncodedAppPrivateKey">The TGS encoded app private key string.</param>
|
||||
/// <returns>A new app auth <see cref="IGitHubClient"/> for the given <paramref name="tgsEncodedAppPrivateKey"/> on success <see langword="null"/> on failure.</returns>
|
||||
IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user