Skip to content

Commit

Permalink
feat: Implement private feeds (#156)
Browse files Browse the repository at this point in the history
* Private feed implementation

* case insensitive username

* Cleanup

* Cleanup

* Implement multi auth

resolves #147

* Prevent null exception.

* Requested changes

* fix comment

* Documentation

* Update config docs

* Update apikeys docs

* feedback impl

* Tests

* Integration tests for basic authentication

* CodeMaid

* Remove redundant call

* Move auth handler and tests

* ApiKey to object

* Move auth config to extend BaGetterApplication

* Api keys move into auth

* docs

* formatting

* VS Package manager console guide

* remove extra space

* Unify line style

* Allow auth policies config

* Using

* Move auth config to Bagetter project

* Remove sealed class

* Auth options comments

* Fix tests

* Fix build

---------

Co-authored-by: seriouz <seriouz@users.noreply.github.com>
  • Loading branch information
tomasfil and seriouz authored Oct 24, 2024
1 parent 087ca20 commit a39e3a3
Show file tree
Hide file tree
Showing 23 changed files with 656 additions and 17 deletions.
70 changes: 67 additions & 3 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BaGetter.Core.Configuration;
using Microsoft.Extensions.Options;

namespace BaGetter.Core;

public class ApiKeyAuthenticationService : IAuthenticationService
{
private readonly string _apiKey;
private readonly ApiKey[] _apiKeys;

public ApiKeyAuthenticationService(IOptionsSnapshot<BaGetterOptions> options)
{
ArgumentNullException.ThrowIfNull(options);

_apiKey = string.IsNullOrEmpty(options.Value.ApiKey) ? null : options.Value.ApiKey;
_apiKeys = options.Value.Authentication?.ApiKeys ?? [];
}

public Task<bool> AuthenticateAsync(string apiKey, CancellationToken cancellationToken)
Expand All @@ -22,8 +26,8 @@ public Task<bool> 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));
}
}
12 changes: 12 additions & 0 deletions src/BaGetter.Core/Authentication/AuthenticationConstants.cs
Original file line number Diff line number Diff line change
@@ -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";
}
11 changes: 11 additions & 0 deletions src/BaGetter.Core/Configuration/ApiKey.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
6 changes: 5 additions & 1 deletion src/BaGetter.Core/Configuration/BaGetterOptions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using BaGetter.Core.Configuration;

namespace BaGetter.Core;

public class BaGetterOptions
{
/// <summary>
/// The API Key required to authenticate package
/// operations. If empty, package operations do not require authentication.
/// operations. If <see cref="ApiKeys"/> and <see cref="ApiKey"/> are not set, package operations do not require authentication.

Check warning on line 9 in src/BaGetter.Core/Configuration/BaGetterOptions.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

XML comment has cref attribute 'ApiKeys' that could not be resolved

Check warning on line 9 in src/BaGetter.Core/Configuration/BaGetterOptions.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

XML comment has cref attribute 'ApiKeys' that could not be resolved

Check warning on line 9 in src/BaGetter.Core/Configuration/BaGetterOptions.cs

View workflow job for this annotation

GitHub Actions / Release packages to nuget.org

XML comment has cref attribute 'ApiKeys' that could not be resolved

Check warning on line 9 in src/BaGetter.Core/Configuration/BaGetterOptions.cs

View workflow job for this annotation

GitHub Actions / Release BaGetter.zip to GitHub

XML comment has cref attribute 'ApiKeys' that could not be resolved
/// </summary>
public string ApiKey { get; set; }

Expand Down Expand Up @@ -64,4 +66,6 @@ public class BaGetterOptions
public HealthCheckOptions HealthCheck { get; set; }

public StatisticsOptions Statistics { get; set; }

public NugetAuthenticationOptions Authentication { get; set; }
}
16 changes: 16 additions & 0 deletions src/BaGetter.Core/Configuration/NugetAuthenticationOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using BaGetter.Core.Configuration;

namespace BaGetter.Core;

public sealed class NugetAuthenticationOptions
{
/// <summary>
/// Username and password credentials for downloading packages.
/// </summary>
public NugetCredentials[] Credentials { get; set; }

/// <summary>
/// Api keys for pushing packages into the feed.
/// </summary>
public ApiKey[] ApiKeys { get; set; }
}
8 changes: 8 additions & 0 deletions src/BaGetter.Core/Configuration/NugetCredentials.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace BaGetter.Core;

public sealed class NugetCredentials
{
public string Username { get; set; }

public string Password { get; set; }
}
4 changes: 2 additions & 2 deletions src/BaGetter.Core/Extensions/DependencyInjectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace BaGetter.Core;

public static partial class DependencyInjectionExtensions
{
public static IServiceCollection AddBaGetterApplication(
public static BaGetterApplication AddBaGetterApplication(
this IServiceCollection services,
Action<BaGetterApplication> configureAction)
{
Expand All @@ -29,7 +29,7 @@ public static IServiceCollection AddBaGetterApplication(

services.AddFallbackServices();

return services;
return app;
}

/// <summary>
Expand Down
100 changes: 100 additions & 0 deletions src/BaGetter.Web/Authentication/NugetBasicAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -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<AuthenticationSchemeOptions>
{
private readonly IOptions<BaGetterOptions> bagetterOptions;

public NugetBasicAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IOptions<BaGetterOptions> bagetterOptions)
: base(options, logger, encoder)
{
this.bagetterOptions = bagetterOptions;
}

protected override Task<AuthenticateResult> 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<AuthenticateResult> 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<AuthenticateResult> 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);
}
}
4 changes: 4 additions & 0 deletions src/BaGetter.Web/Controllers/PackageContentController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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
/// </summary>

[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)]
public class PackageContentController : Controller
{
private readonly IPackageContentService _content;
Expand Down
4 changes: 4 additions & 0 deletions src/BaGetter.Web/Controllers/PackageMetadataController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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
/// </summary>

[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)]
public class PackageMetadataController : Controller
{
private readonly IPackageMetadataService _metadata;
Expand Down
3 changes: 3 additions & 0 deletions src/BaGetter.Web/Controllers/SearchController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/BaGetter.Web/Controllers/ServiceIndexController.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
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;

/// <summary>
/// The NuGet Service Index. This aids NuGet client to discover this server's services.
/// </summary>

[Authorize(AuthenticationSchemes = AuthenticationConstants.NugetBasicAuthenticationScheme, Policy = AuthenticationConstants.NugetUserPolicy)]
public class ServiceIndexController : Controller
{
private readonly IServiceIndexService _serviceIndex;
Expand Down
Loading

0 comments on commit a39e3a3

Please sign in to comment.