Add git credentials validation

Closes #1876
This commit is contained in:
Jordan Dominion
2024-09-03 23:13:11 -04:00
parent 9fe0c97d2a
commit 91f56e5f85
7 changed files with 235 additions and 53 deletions
@@ -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);
}
}