diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index a443862fa..363d713ad 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -17,6 +17,27 @@ To do so, you can insert the desired API key in the `ApiKey` field. } ``` +You can also use the `ApiKeys` array in order to manage multiple API keys for multiple teams/developers. + +```json +{ + "Authentication": { + "ApiKeys": [ + { + "Key" : "NUGET-SERVER-API-KEY-1" + }, + { + "Key" : "NUGET-SERVER-API-KEY-2" + } + ] + ... + } + ... +} +``` + +Both `ApiKey` and `ApiKeys` work in conjunction additively eg.: `or` `||` logical operator. + Users will now have to provide the API key to push packages: ```shell @@ -200,11 +221,54 @@ Pushing a package with a pre-release version like "3.1.0-SNAPSHOT" will overwrit A private feed requires users to authenticate before accessing packages. -:::warning +You can require that users provide a username and password to access the nuget feed. +To do so, you can insert the credentials in the `Authentication` section. -Private feeds are not supported at this time! See [this pull request](https://github.com/loic-sharma/BaGet/pull/69) for more information. +```json +{ + "Authentication": { + "Credentials": [ + { + "Username": "username", + "Password": "password" + } + ] + ... + } + ... +} +``` -::: +Users will now have to provide the username and password to fetch and download packages. + +How to add private nuget feed: + +1. Download the latest NuGet executable. +2. Open a Command Prompt and change the path to the nuget.exe location. +3. The command from the example below stores a token in the %AppData%\NuGet\NuGet.config file. Your original credentials cannot be obtained from this token. + + +```shell +NuGet Sources Add -Name "localhost" -Source "http://localhost:5000/v3/index.json" -UserName "username" -Password "password" +``` + +If you are unable to connect to the feed by using encrypted credentials, store your credentials in clear text: + +```shell +NuGet Sources Add -Name "localhost" -Source "http://localhost:5000/v3/index.json" -UserName "username" -Password "password" -StorePasswordInClearText +``` + +If you have already stored a token instead of storing the credentials as clear text, update the definition in the %AppData%\NuGet\NuGet.config file by using the following command: + +```shell +NuGet Sources Update -Name "localhost" -Source "http://localhost:5000/v3/index.json" -UserName "username" -Password "password" -StorePasswordInClearText +``` + +The commands are slightly different when using the Package Manager console in Visual Studio: + +```shell +dotnet nuget add source "http://localhost:5000/v3/index.json" --name "bagetter" --username "username" --password "password" +``` ## Database configuration diff --git a/src/BaGetter.Core/Authentication/ApiKeyAuthenticationService.cs b/src/BaGetter.Core/Authentication/ApiKeyAuthenticationService.cs index 3c69252b9..1a7cfb54b 100644 --- a/src/BaGetter.Core/Authentication/ApiKeyAuthenticationService.cs +++ b/src/BaGetter.Core/Authentication/ApiKeyAuthenticationService.cs @@ -1,6 +1,8 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using BaGetter.Core.Configuration; using Microsoft.Extensions.Options; namespace BaGetter.Core; @@ -8,12 +10,14 @@ namespace BaGetter.Core; public class ApiKeyAuthenticationService : IAuthenticationService { private readonly string _apiKey; + private readonly ApiKey[] _apiKeys; public ApiKeyAuthenticationService(IOptionsSnapshot options) { ArgumentNullException.ThrowIfNull(options); _apiKey = string.IsNullOrEmpty(options.Value.ApiKey) ? null : options.Value.ApiKey; + _apiKeys = options.Value.Authentication?.ApiKeys ?? []; } public Task AuthenticateAsync(string apiKey, CancellationToken cancellationToken) @@ -22,8 +26,8 @@ public Task AuthenticateAsync(string apiKey, CancellationToken cancellatio private bool Authenticate(string apiKey) { // No authentication is necessary if there is no required API key. - if (_apiKey == null) return true; + if (_apiKey == null && (_apiKeys.Length == 0)) return true; - return _apiKey == apiKey; + return _apiKey == apiKey || _apiKeys.Any(x => x.Key.Equals(apiKey)); } } diff --git a/src/BaGetter.Core/Authentication/AuthenticationConstants.cs b/src/BaGetter.Core/Authentication/AuthenticationConstants.cs new file mode 100644 index 000000000..f6ff708ea --- /dev/null +++ b/src/BaGetter.Core/Authentication/AuthenticationConstants.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BaGetter.Authentication; +public static class AuthenticationConstants +{ + public const string NugetBasicAuthenticationScheme = "NugetBasicAuthentication"; + public const string NugetUserPolicy = "NuGetUserPolicy"; +} diff --git a/src/BaGetter.Core/Configuration/ApiKey.cs b/src/BaGetter.Core/Configuration/ApiKey.cs new file mode 100644 index 000000000..bb94f661a --- /dev/null +++ b/src/BaGetter.Core/Configuration/ApiKey.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BaGetter.Core.Configuration; +public class ApiKey +{ + public string Key { get; set; } +} diff --git a/src/BaGetter.Core/Configuration/BaGetterOptions.cs b/src/BaGetter.Core/Configuration/BaGetterOptions.cs index a3e19ddd0..9caa850c1 100644 --- a/src/BaGetter.Core/Configuration/BaGetterOptions.cs +++ b/src/BaGetter.Core/Configuration/BaGetterOptions.cs @@ -1,10 +1,12 @@ +using BaGetter.Core.Configuration; + namespace BaGetter.Core; public class BaGetterOptions { /// /// The API Key required to authenticate package - /// operations. If empty, package operations do not require authentication. + /// operations. If and are not set, package operations do not require authentication. /// public string ApiKey { get; set; } @@ -64,4 +66,6 @@ public class BaGetterOptions public HealthCheckOptions HealthCheck { get; set; } public StatisticsOptions Statistics { get; set; } + + public NugetAuthenticationOptions Authentication { get; set; } } diff --git a/src/BaGetter.Core/Configuration/NugetAuthenticationOptions.cs b/src/BaGetter.Core/Configuration/NugetAuthenticationOptions.cs new file mode 100644 index 000000000..4d608fd8b --- /dev/null +++ b/src/BaGetter.Core/Configuration/NugetAuthenticationOptions.cs @@ -0,0 +1,16 @@ +using BaGetter.Core.Configuration; + +namespace BaGetter.Core; + +public sealed class NugetAuthenticationOptions +{ + /// + /// Username and password credentials for downloading packages. + /// + public NugetCredentials[] Credentials { get; set; } + + /// + /// Api keys for pushing packages into the feed. + /// + public ApiKey[] ApiKeys { get; set; } +} diff --git a/src/BaGetter.Core/Configuration/NugetCredentials.cs b/src/BaGetter.Core/Configuration/NugetCredentials.cs new file mode 100644 index 000000000..5cd5579bf --- /dev/null +++ b/src/BaGetter.Core/Configuration/NugetCredentials.cs @@ -0,0 +1,8 @@ +namespace BaGetter.Core; + +public sealed class NugetCredentials +{ + public string Username { get; set; } + + public string Password { get; set; } +} diff --git a/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.cs b/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.cs index a0cfc0e21..9869767a1 100644 --- a/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.cs +++ b/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.cs @@ -15,7 +15,7 @@ namespace BaGetter.Core; public static partial class DependencyInjectionExtensions { - public static IServiceCollection AddBaGetterApplication( + public static BaGetterApplication AddBaGetterApplication( this IServiceCollection services, Action configureAction) { @@ -29,7 +29,7 @@ public static IServiceCollection AddBaGetterApplication( services.AddFallbackServices(); - return services; + return app; } /// diff --git a/src/BaGetter.Web/Authentication/NugetBasicAuthenticationHandler.cs b/src/BaGetter.Web/Authentication/NugetBasicAuthenticationHandler.cs new file mode 100644 index 000000000..ef5031e79 --- /dev/null +++ b/src/BaGetter.Web/Authentication/NugetBasicAuthenticationHandler.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text; +using System.Threading.Tasks; +using System; +using BaGetter.Core; +using System.Linq; + +namespace BaGetter.Web.Authentication; + +public class NugetBasicAuthenticationHandler : AuthenticationHandler +{ + private readonly IOptions bagetterOptions; + + public NugetBasicAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IOptions bagetterOptions) + : base(options, logger, encoder) + { + this.bagetterOptions = bagetterOptions; + } + + protected override Task HandleAuthenticateAsync() + { + if (IsAnonymousAllowed()) + { + return CreateAnonymousAuthenticatonResult(); + } + + if (!Request.Headers.TryGetValue("Authorization", out var auth)) + return Task.FromResult(AuthenticateResult.NoResult()); + + string username = null; + string password = null; + try + { + var authHeader = AuthenticationHeaderValue.Parse(auth); + var credentialBytes = Convert.FromBase64String(authHeader.Parameter); + var credentials = Encoding.UTF8.GetString(credentialBytes).Split([':'], 2); + username = credentials[0]; + password = credentials[1]; + } + catch + { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + if (!ValidateCredentials(username, password)) + return Task.FromResult(AuthenticateResult.Fail("Invalid Username or Password")); + + return CreateUserAuthenticatonResult(username); + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.Headers.WWWAuthenticate = "Basic realm=\"NuGet Server\""; + await base.HandleChallengeAsync(properties); + } + + private Task CreateAnonymousAuthenticatonResult() + { + Claim[] claims = [new Claim(ClaimTypes.Anonymous, string.Empty)]; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + private Task CreateUserAuthenticatonResult(string username) + { + Claim[] claims = [new Claim(ClaimTypes.Name, username)]; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + private bool IsAnonymousAllowed() + { + return bagetterOptions.Value.Authentication is null || + bagetterOptions.Value.Authentication.Credentials is null || + bagetterOptions.Value.Authentication.Credentials.Length == 0 || + bagetterOptions.Value.Authentication.Credentials.All(a => string.IsNullOrWhiteSpace(a.Username) && string.IsNullOrWhiteSpace(a.Password)); + } + + private bool ValidateCredentials(string username, string password) + { + return bagetterOptions.Value.Authentication.Credentials.Any(a => a.Username.Equals(username, StringComparison.OrdinalIgnoreCase) && a.Password == password); + } +} diff --git a/src/BaGetter.Web/Controllers/PackageContentController.cs b/src/BaGetter.Web/Controllers/PackageContentController.cs index 2d995ee1b..62e3bfdd1 100644 --- a/src/BaGetter.Web/Controllers/PackageContentController.cs +++ b/src/BaGetter.Web/Controllers/PackageContentController.cs @@ -1,8 +1,10 @@ using System; using System.Threading; using System.Threading.Tasks; +using BaGetter.Authentication; using BaGetter.Core; using BaGetter.Protocol.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NuGet.Versioning; @@ -12,6 +14,8 @@ namespace BaGetter.Web; /// The Package Content resource, used to download content from packages. /// See: https://docs.microsoft.com/nuget/api/package-base-address-resource /// + +[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)] public class PackageContentController : Controller { private readonly IPackageContentService _content; diff --git a/src/BaGetter.Web/Controllers/PackageMetadataController.cs b/src/BaGetter.Web/Controllers/PackageMetadataController.cs index f8dee89e3..914068f5b 100644 --- a/src/BaGetter.Web/Controllers/PackageMetadataController.cs +++ b/src/BaGetter.Web/Controllers/PackageMetadataController.cs @@ -1,8 +1,10 @@ using System; using System.Threading; using System.Threading.Tasks; +using BaGetter.Authentication; using BaGetter.Core; using BaGetter.Protocol.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NuGet.Versioning; @@ -12,6 +14,8 @@ namespace BaGetter.Web; /// The Package Metadata resource, used to fetch packages' information. /// See: https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource /// + +[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)] public class PackageMetadataController : Controller { private readonly IPackageMetadataService _metadata; diff --git a/src/BaGetter.Web/Controllers/SearchController.cs b/src/BaGetter.Web/Controllers/SearchController.cs index 58feec4dc..fe39d2d63 100644 --- a/src/BaGetter.Web/Controllers/SearchController.cs +++ b/src/BaGetter.Web/Controllers/SearchController.cs @@ -1,12 +1,15 @@ using System; using System.Threading; using System.Threading.Tasks; +using BaGetter.Authentication; using BaGetter.Core; using BaGetter.Protocol.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace BaGetter.Web; +[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)] public class SearchController : Controller { private readonly ISearchService _searchService; diff --git a/src/BaGetter.Web/Controllers/ServiceIndexController.cs b/src/BaGetter.Web/Controllers/ServiceIndexController.cs index d79e12537..2e66edff1 100644 --- a/src/BaGetter.Web/Controllers/ServiceIndexController.cs +++ b/src/BaGetter.Web/Controllers/ServiceIndexController.cs @@ -1,8 +1,10 @@ using System; using System.Threading; using System.Threading.Tasks; +using BaGetter.Authentication; using BaGetter.Core; using BaGetter.Protocol.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace BaGetter.Web; @@ -10,6 +12,8 @@ namespace BaGetter.Web; /// /// The NuGet Service Index. This aids NuGet client to discover this server's services. /// + +[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)] public class ServiceIndexController : Controller { private readonly IServiceIndexService _serviceIndex; diff --git a/src/BaGetter.Web/Controllers/SymbolController.cs b/src/BaGetter.Web/Controllers/SymbolController.cs index 3554a15b8..2cd99a383 100644 --- a/src/BaGetter.Web/Controllers/SymbolController.cs +++ b/src/BaGetter.Web/Controllers/SymbolController.cs @@ -1,13 +1,16 @@ using System; using System.Threading; using System.Threading.Tasks; +using BaGetter.Authentication; using BaGetter.Core; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace BaGetter.Web; +[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)] public class SymbolController : Controller { private readonly IAuthenticationService _authentication; diff --git a/src/BaGetter.Web/Extensions/IServiceCollectionExtensions.cs b/src/BaGetter.Web/Extensions/IServiceCollectionExtensions.cs index 65de6b80c..2b08aecee 100644 --- a/src/BaGetter.Web/Extensions/IServiceCollectionExtensions.cs +++ b/src/BaGetter.Web/Extensions/IServiceCollectionExtensions.cs @@ -1,8 +1,11 @@ using System; using System.Text.Json.Serialization; +using BaGetter.Authentication; using BaGetter.Core; using BaGetter.Web; +using BaGetter.Web.Authentication; using BaGetter.Web.Helper; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; namespace BaGetter; @@ -28,7 +31,8 @@ public static IServiceCollection AddBaGetterWebApplication( services.AddTransient(); services.AddSingleton(ApplicationVersionHelper.GetVersion()); - services.AddBaGetterApplication(configureAction); + + var app = services.AddBaGetterApplication(configureAction); return services; } diff --git a/src/BaGetter/IServiceCollectionExtensions.cs b/src/BaGetter/IServiceCollectionExtensions.cs new file mode 100644 index 000000000..f2bc7b005 --- /dev/null +++ b/src/BaGetter/IServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +using BaGetter.Authentication; +using BaGetter.Core; +using BaGetter.Web.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace BaGetter; + +internal static class IServiceCollectionExtensions +{ + internal static BaGetterApplication AddNugetBasicHttpAuthentication(this BaGetterApplication app) + { + app.Services.AddAuthentication(options => + { + // Breaks existing tests if the contains check is not here. + if (!options.SchemeMap.ContainsKey(AuthenticationConstants.NugetBasicAuthenticationScheme)) + { + options.AddScheme(AuthenticationConstants.NugetBasicAuthenticationScheme, AuthenticationConstants.NugetBasicAuthenticationScheme); + options.DefaultAuthenticateScheme = AuthenticationConstants.NugetBasicAuthenticationScheme; + options.DefaultChallengeScheme = AuthenticationConstants.NugetBasicAuthenticationScheme; + } + }); + + return app; + } + + internal static BaGetterApplication AddNugetBasicHttpAuthorization(this BaGetterApplication app, Action? configurePolicy = null) + { + app.Services.AddAuthorization(options => + { + options.AddPolicy(AuthenticationConstants.NugetUserPolicy, policy => + { + policy.RequireAuthenticatedUser(); + configurePolicy?.Invoke(policy); + }); + }); + + return app; + } +} diff --git a/src/BaGetter/Startup.cs b/src/BaGetter/Startup.cs index 45c0f74cd..8b549e795 100644 --- a/src/BaGetter/Startup.cs +++ b/src/BaGetter/Startup.cs @@ -1,8 +1,13 @@ using System; +using BaGetter.Authentication; using BaGetter.Core; using BaGetter.Core.Extensions; using BaGetter.Tencent; using BaGetter.Web; +using BaGetter.Web.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation; @@ -49,6 +54,10 @@ public void ConfigureServices(IServiceCollection services) private void ConfigureBaGetterApplication(BaGetterApplication app) { + //Add base authentication and authorization + app.AddNugetBasicHttpAuthentication(); + app.AddNugetBasicHttpAuthorization(); + // Add database providers. app.AddAzureTableDatabase(); app.AddMySqlDatabase(); @@ -83,9 +92,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UsePathBase(options.PathBase); app.UseStaticFiles(); + app.UseAuthentication(); app.UseRouting(); + app.UseAuthorization(); app.UseCors(ConfigureBaGetterServer.CorsPolicy); + app.UseOperationCancelledMiddleware(); app.UseEndpoints(endpoints => diff --git a/src/BaGetter/appsettings.json b/src/BaGetter/appsettings.json index 9742d642e..5d03a15f9 100644 --- a/src/BaGetter/appsettings.json +++ b/src/BaGetter/appsettings.json @@ -52,11 +52,27 @@ }, "HealthCheck": { - "Path" : "/health" + "Path": "/health" }, "Statistics": { "EnableStatisticsPage": true, "ListConfiguredServices": true } + + //"Authentication": { + // "Credentials": [ + // { + // "Username": "username", + // "Password": "password" + // } + // ], + // "ApiKeys": [ + // { + // "Key": "key" + // } + // ] + //} + + } diff --git a/tests/BaGetter.Core.Tests/Upstream/UpstreamAuthenticationTests.cs b/tests/BaGetter.Core.Tests/Upstream/UpstreamAuthenticationTests.cs index 0c5908bc3..f58bf67ac 100644 --- a/tests/BaGetter.Core.Tests/Upstream/UpstreamAuthenticationTests.cs +++ b/tests/BaGetter.Core.Tests/Upstream/UpstreamAuthenticationTests.cs @@ -121,12 +121,16 @@ private static (IServiceProvider serivces, Mock mockHandler) var serviceProvider = new ServiceCollection() .AddSingleton(new ConfigurationBuilder().Build()) .AddSingleton(new HttpClient(mockHandler.Object)) - .AddBaGetterApplication(app => { }) - .Configure(opt => + .AddBaGetterApplication(app => { - opt.PackageSource = new Uri("http://localhost/v3/index.json"); - setupOptions(opt); + app.Services + .Configure(opt => + { + opt.PackageSource = new Uri("http://localhost/v3/index.json"); + setupOptions(opt); + }); }) + .Services .BuildServiceProvider(); serviceProvider.GetRequiredService(); diff --git a/tests/BaGetter.Tests/NugetAllowAnonymousAuthenticationIntegrationTests.cs b/tests/BaGetter.Tests/NugetAllowAnonymousAuthenticationIntegrationTests.cs new file mode 100644 index 000000000..d0e780277 --- /dev/null +++ b/tests/BaGetter.Tests/NugetAllowAnonymousAuthenticationIntegrationTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using BaGetter.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Xunit; +using Xunit.Abstractions; + +namespace BaGetter.Tests; + +public class NugetAllowAnonymousAuthenticationIntegrationTests : IDisposable +{ + private readonly BaGetterApplication _app; + private readonly HttpClient _client; + private readonly ITestOutputHelper _output; + + public NugetAllowAnonymousAuthenticationIntegrationTests(ITestOutputHelper output) + { + _output = output; + _app = new BaGetterApplication(_output, null); + _client = _app.CreateClient(); + } + + [Fact] + public async Task AnonymousAccess_WhenAnonymousAllowed_ReturnsOk() + { + // Act + using var response = await _client.GetAsync("v3/index.json"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Credentials_WhenAnonymousAllowed_ReturnsOk() + { + // Arrange + _client.DefaultRequestHeaders.Add( + "Authorization", + (IEnumerable)new StringValues($"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"x:x"))}")); + + // Act + using var response = await _client.GetAsync("v3/index.json"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + public void Dispose() + { + _app.Dispose(); + _client.Dispose(); + } +} diff --git a/tests/BaGetter.Tests/NugetAuthenticationIntegrationTests.cs b/tests/BaGetter.Tests/NugetAuthenticationIntegrationTests.cs new file mode 100644 index 000000000..0ce6f553f --- /dev/null +++ b/tests/BaGetter.Tests/NugetAuthenticationIntegrationTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using BaGetter.Authentication; +using BaGetter.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Xunit; +using Xunit.Abstractions; + +namespace BaGetter.Tests; + +public class NugetAuthenticationIntegrationTests : IDisposable +{ + private const string Username = "username"; + private const string Password = "password"; + private readonly BaGetterApplication _app; + private readonly HttpClient _client; + private readonly ITestOutputHelper _output; + + public NugetAuthenticationIntegrationTests(ITestOutputHelper output) + { + _output = output; + _app = new BaGetterApplication(_output, null, dict => + { + dict.Add("Authentication:Credentials:0:Username", Username); + dict.Add("Authentication:Credentials:0:Password", Password); + }); + _client = _app.CreateClient(); + } + + [Fact] + public async Task AnonymousAccess_WhenAnonymousNotAllowed_ReturnsUnauthorized() + { + // Act + using var response = await _client.GetAsync("v3/index.json"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ValidCredentialsAccess_WhenAnonymousNotAllowed_ReturnsOk() + { + // Arrange + _client.DefaultRequestHeaders.Authorization = new(AuthenticationConstants.NugetBasicAuthenticationScheme, $"{Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))}"); + + // Act + using var response = await _client.GetAsync("v3/index.json"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task InvalidCredentialsAccess_WhenAnonymousNotAllowed_ReturnsUnauthorized() + { + // Arrange + _client.DefaultRequestHeaders.Authorization = new(AuthenticationConstants.NugetBasicAuthenticationScheme, $"{Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}x"))}"); + + // Act + using var response = await _client.GetAsync("v3/index.json"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + public void Dispose() + { + _app.Dispose(); + _client.Dispose(); + } +} diff --git a/tests/BaGetter.Tests/Support/BaGetApplication.cs b/tests/BaGetter.Tests/Support/BaGetApplication.cs index 128d95bbc..6888e71e0 100644 --- a/tests/BaGetter.Tests/Support/BaGetApplication.cs +++ b/tests/BaGetter.Tests/Support/BaGetApplication.cs @@ -4,7 +4,9 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using BaGetter.Authentication; using BaGetter.Core; +using BaGetter.Web.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; @@ -23,11 +25,13 @@ public class BaGetterApplication : WebApplicationFactory { private readonly ITestOutputHelper _output; private readonly HttpClient _upstreamClient; + private readonly Action> _inMemoryConfiguration; - public BaGetterApplication(ITestOutputHelper output, HttpClient upstreamClient = null) + public BaGetterApplication(ITestOutputHelper output, HttpClient upstreamClient = null, Action> inMemoryConfiguration = null) { _output = output ?? throw new ArgumentNullException(nameof(output)); _upstreamClient = upstreamClient; + this._inMemoryConfiguration = inMemoryConfiguration; } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -58,7 +62,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) .ConfigureAppConfiguration(config => { // Setup the integration test configuration. - config.AddInMemoryCollection(new Dictionary + var dict = new Dictionary { { "Database:Type", "Sqlite" }, { "Database:ConnectionString", $"Data Source={sqlitePath}" }, @@ -67,7 +71,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { "Search:Type", "Database" }, { "Mirror:Enabled", _upstreamClient != null ? "true": "false" }, { "Mirror:PackageSource", "http://localhost/v3/index.json" }, - }); + }; + _inMemoryConfiguration?.Invoke(dict); + + config.AddInMemoryCollection(dict); + }) .ConfigureServices((context, services) => { diff --git a/tests/BaGetter.Web.Tests/Authentication/NugetBasicAuthenticationHandlerTests.cs b/tests/BaGetter.Web.Tests/Authentication/NugetBasicAuthenticationHandlerTests.cs new file mode 100644 index 000000000..d55bcd202 --- /dev/null +++ b/tests/BaGetter.Web.Tests/Authentication/NugetBasicAuthenticationHandlerTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using BaGetter.Core; +using BaGetter.Web.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Moq; +using Xunit; + +namespace BaGetter.Web.Tests; + +public class NugetBasicAuthenticationHandlerTests +{ + private readonly Mock> _bagetterOptions; + private readonly UrlEncoder _encoder; + private readonly Mock _httpContext; + private readonly Mock _httpRequest; + private readonly Mock _httpResponse; + private readonly Mock _loggerFactory; + private readonly Mock> _options; + + public NugetBasicAuthenticationHandlerTests() + { + _options = new Mock>(); + _options.Setup(x => x.Get(It.IsAny())).Returns(new AuthenticationSchemeOptions()); + + _loggerFactory = new Mock(); + _loggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(Mock.Of>()); + + _encoder = UrlEncoder.Default; + + _bagetterOptions = new Mock>(); + + _httpContext = new Mock(); + _httpRequest = new Mock(); + _httpResponse = new Mock(); + + _httpContext.SetupGet(x => x.Request).Returns(_httpRequest.Object); + _httpContext.SetupGet(x => x.Response).Returns(_httpResponse.Object); + } + + [Fact] + public async Task HandleAuthenticateAsync_AnonymousAllowed_ReturnsSuccessResult() + { + // Arrange + SetupBaGetterOptions(new BaGetterOptions()); + var handler = CreateHandler(); + + // Act + var result = await handler.AuthenticateAsync(); + + // Assert + Assert.True(result.Succeeded); + Assert.True(result.Principal.HasClaim(ClaimTypes.Anonymous, string.Empty)); + } + + [Fact] + public async Task HandleAuthenticateAsync_InvalidAuthorizationHeader_ReturnsFailResult() + { + // Arrange + SetupBaGetterOptions(new BaGetterOptions + { + Authentication = new NugetAuthenticationOptions + { + Credentials = [new NugetCredentials { Username = "user", Password = "pass" }] + } + }); + _httpRequest.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", new StringValues("InvalidHeader") } + }); + var handler = CreateHandler(); + + // Act + var result = await handler.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("Invalid Authorization Header", result.Failure.Message); + } + + [Fact] + public async Task HandleAuthenticateAsync_InvalidCredentials_ReturnsFailResult() + { + // Arrange + SetupBaGetterOptions(new BaGetterOptions + { + Authentication = new NugetAuthenticationOptions + { + Credentials = [new NugetCredentials { Username = "user", Password = "pass" }] + } + }); + _httpRequest.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", new StringValues($"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("invaliduser:invalidpass"))}") } + }); + var handler = CreateHandler(); + + // Act + var result = await handler.AuthenticateAsync(); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("Invalid Username or Password", result.Failure.Message); + } + + [Fact] + public async Task HandleAuthenticateAsync_NoAuthorizationHeader_ReturnsNoResult() + { + // Arrange + SetupBaGetterOptions(new BaGetterOptions + { + Authentication = new NugetAuthenticationOptions + { + Credentials = [new NugetCredentials { Username = "user", Password = "pass" }] + } + }); + _httpRequest.Setup(r => r.Headers).Returns(new HeaderDictionary()); + var handler = CreateHandler(); + + // Act + var result = await handler.AuthenticateAsync(); + + // Assert + Assert.True(result.None); + } + + [Fact] + public async Task HandleAuthenticateAsync_ValidCredentials_ReturnsSuccessResult() + { + // Arrange + const string username = "testuser"; + const string password = "testpass"; + SetupBaGetterOptions(new BaGetterOptions + { + Authentication = new NugetAuthenticationOptions + { + Credentials = [new NugetCredentials { Username = username, Password = password }] + } + }); + _httpRequest.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", new StringValues($"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))}") } + }); + var handler = CreateHandler(); + + // Act + var result = await handler.AuthenticateAsync(); + + // Assert + Assert.True(result.Succeeded); + Assert.True(result.Principal.HasClaim(ClaimTypes.Name, username)); + } + + private NugetBasicAuthenticationHandler CreateHandler() + { + var handler = new NugetBasicAuthenticationHandler( + _options.Object, + _loggerFactory.Object, + _encoder, + _bagetterOptions.Object); + + handler.InitializeAsync(new AuthenticationScheme("Basic", null, typeof(NugetBasicAuthenticationHandler)), _httpContext.Object).GetAwaiter().GetResult(); + + return handler; + } + + private void SetupBaGetterOptions(BaGetterOptions options) + { + _bagetterOptions.Setup(x => x.Value).Returns(options); + } +}