Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JWT-based Auth option for API requests #900

Merged
merged 8 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/protagonist/.editorconfig
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[*.cs]
csharp_style_namespace_declarations=file_scoped:warning
csharp_style_namespace_declarations=file_scoped:warning
csharp_prefer_braces = true:suggestion
3 changes: 2 additions & 1 deletion src/protagonist/API/API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<LangVersion>default</LangVersion>
<LangVersion>10.0</LangVersion>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

Expand All @@ -23,6 +23,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="6.0.5" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.22" />
<PackageReference Include="Serilog.AspNetCore" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
Expand Down
230 changes: 179 additions & 51 deletions src/protagonist/API/Auth/DlcsBasicAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;

namespace API.Auth;

Expand All @@ -22,7 +23,7 @@ namespace API.Auth;
/// to an anonymous user. Another example: https://api.dlcs.io/originStrategies
/// - An admin API key is allowed to make any valid API call.
/// - A customer path is one that starts /customers/n/, where n is the customer id.
/// Customer-specific resources such as images, auth services, etc live under these paths.
/// Customer-specific resources such as images, auth services, etc. live under these paths.
/// - Only an admin key or a key belonging to that customer can call these paths.
/// - A customer path is never anonymous.
///
Expand All @@ -35,6 +36,9 @@ public class DlcsBasicAuthenticationHandler : AuthenticationHandler<BasicAuthent
{
private readonly ICustomerRepository customerRepository;
private readonly DlcsApiAuth dlcsApiAuth;
private readonly JwtAuthHelper authHelper;

private static readonly JsonWebTokenHandler JwtHandler = new();

/// <summary>
/// Deduces the caller's claims
Expand All @@ -50,11 +54,13 @@ public DlcsBasicAuthenticationHandler(
UrlEncoder encoder,
ISystemClock clock,
ICustomerRepository customerRepository,
DlcsApiAuth dlcsApiAuth)
DlcsApiAuth dlcsApiAuth,
JwtAuthHelper authHelper)
: base(options, logger, encoder, clock)
{
this.customerRepository = customerRepository;
this.dlcsApiAuth = dlcsApiAuth;
this.authHelper = authHelper;
}

/// <summary>
Expand All @@ -66,13 +72,19 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
// Not all API paths require auth...
var endpoint = Context.GetEndpoint();
if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null)
{
return AuthenticateResult.NoResult();

}

// ...but any not marked must have the auth header
if (!Request.Headers.ContainsKey("Authorization"))
{
return AuthenticateResult.Fail("Missing Authorization Header in request");
}

var headerValue = Request.GetAuthHeaderValue(AuthenticationHeaderUtils.BasicScheme)
?? Request.GetAuthHeaderValue(AuthenticationHeaderUtils.BearerTokenScheme);

var headerValue = Request.GetAuthHeaderValue(AuthenticationHeaderUtils.BasicScheme);
if (headerValue == null)
{
return AuthenticateResult.Fail("Missing Authorization Header in request");
Expand All @@ -81,99 +93,197 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
// for a path like /customers/23/queue, the resourceCustomerId is 23.
// This isn't necessarily the customer that owns the api key being used on the call!
int? resourceCustomerId = null;
if (Request.RouteValues.TryGetValue("customerId", out var customerIdRouteVal))
{
if (customerIdRouteVal != null && int.TryParse(customerIdRouteVal.ToString(), out int result))
{
resourceCustomerId = result;
}
}
var apiCaller = GetApiCaller(headerValue);
if (apiCaller == null)
if (Request.RouteValues.TryGetValue("customerId", out var customerIdRouteVal)
&& customerIdRouteVal != null
&& int.TryParse(customerIdRouteVal.ToString(), out var result))
{
return AuthenticateResult.Fail("Invalid credentials");
resourceCustomerId = result;
}

var customerForKey = await customerRepository.GetCustomerForKey(apiCaller.Key, resourceCustomerId);
if (customerForKey == null)

return await GetApiCaller(headerValue, resourceCustomerId) switch
{
return AuthenticateResult.Fail("No customer found for this key that is permitted to access this resource");
}
null => AuthenticateResult.Fail("Invalid credentials"),
FailedCaller fail => AuthenticateResult.Fail(fail.Message),
// Success:
ApiCaller apiCaller => AuthenticateApiCaller(apiCaller, resourceCustomerId),
// Unlikely:
_ => throw new InvalidOperationException()
};
}

if (apiCaller.Secret != dlcsApiAuth.GetApiSecret(customerForKey, Options.Salt, apiCaller.Key))
private AuthenticateResult AuthenticateApiCaller(ApiCaller apiCaller, int? resourceCustomerId)
{
if (apiCaller.Customer is not { } customer)
{
return AuthenticateResult.Fail("Invalid credentials");
Logger.LogError("A non-fail ApiCaller returned with null Customer field");
return AuthenticateResult.Fail("Unexpected error");
}

// We still have some checks to do before authenticating this user.
// Some of these could be classed as authorisation rather than authentication, though they are not user-specific.
if (resourceCustomerId.HasValue)
{
// the request is for a particular customer's resource (e.g., an asset)
if (customerForKey.Id != resourceCustomerId.Value)
if (customer.Id != resourceCustomerId.Value)
{
// ... but the requester is not this customer. Only proceed if they are admin.
if (!customerForKey.Administrator)
if (!customer.Administrator)
{
return AuthenticateResult.Fail("Only admin user may access this customer's resource.");
}

Logger.LogDebug("Admin key accessing a customer's resource");
}
}
else
{
// The request isn't for a customer resource (i.e., under the path /customers/n/).
// Only proceed if they are admin.
if (!customerForKey.Administrator)
if (!customer.Administrator)
{
return AuthenticateResult.Fail("Only admin user may access this shared resource");
}

Logger.LogDebug("Admin key accessing a shared resource");
}

// At this point our *authentication* has passed.
// Downstream handlers may still refuse to *authorise* the request for other reasons
// (it can still end up a 401)
Logger.LogTrace("Authentication passed");

var claims = new List<Claim>
{
new (ClaimTypes.Name, customerForKey.Name), // TODO - should these be the other way round?
new (ClaimTypes.NameIdentifier, apiCaller.Key), // ^^^
new (ClaimsPrincipalUtils.Claims.Customer, customerForKey.Id.ToString()),
new (ClaimTypes.Role, ClaimsPrincipalUtils.Roles.Customer),
new(ClaimTypes.Name, customer.Name), // TODO - should these be the other way round?
new(ClaimTypes.NameIdentifier, apiCaller.Key), // ^^^
new(ClaimsPrincipalUtils.Claims.Customer, customer.Id.ToString()),
new(ClaimTypes.Role, ClaimsPrincipalUtils.Roles.Customer),
};
if (customerForKey.Administrator)
if (customer.Administrator)
{
claims.Add(new Claim(ClaimTypes.Role, ClaimsPrincipalUtils.Roles.Admin));
claims.Add(new(ClaimTypes.Role, ClaimsPrincipalUtils.Roles.Admin));
}

var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}

private ApiCaller? GetApiCaller(AuthenticationHeaderValue headerValue)
private async Task<IApiCaller?> GetApiCaller(AuthenticationHeaderValue headerValue, int? customerIdHint)
{
try
{
if (headerValue.Parameter == null) return null;

var authHeader = headerValue.Parameter.DecodeBase64();
string[] keyAndSecret = authHeader.Split(':');
var apiCaller = new ApiCaller(keyAndSecret[0], keyAndSecret[1]);
if (apiCaller.Key.HasText() && apiCaller.Secret.HasText())
{
return apiCaller;
}
switch (headerValue.Scheme)
{
case AuthenticationHeaderUtils.BasicScheme:
return await GetApiCallerFromBasic(headerValue.Parameter, customerIdHint);
case AuthenticationHeaderUtils.BearerTokenScheme:
return await GetApiCallerFromJwt(headerValue.Parameter);
}

Logger.LogError("Could not parse auth header");
return null;
}

private async Task<IApiCaller?> GetApiCallerFromBasic(string? headerValueParameter, int? customerIdHint)
{
var parts = headerValueParameter?.DecodeBase64().Split(':');
if (parts?.Length != 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
{
return null;
}
catch
var key = parts[0];
var secret = parts[1];

var customerForKey = await customerRepository.GetCustomerForKey(key, customerIdHint);
if (customerForKey == null)
{
Logger.LogError("Could not parse auth header");
return new FailedCaller("No customer found for this key that is permitted to access this resource");
}
return null;

if (secret != dlcsApiAuth.GetApiSecret(customerForKey, Options.Salt, key))
{
return new FailedCaller("Invalid credentials");
}

return new ApiCaller(key, customerForKey);

}

private async Task<IApiCaller?> GetApiCallerFromJwt(string? token)
{
if (authHelper.SigningCredentials?.Key is not { } signingKey)
{
return new FailedCaller("JWT not enabled");
}

if (token is null)
{
return new FailedCaller("JWT missing");
}

var result = await JwtHandler.ValidateTokenAsync(token, new()
{
IssuerSigningKey = signingKey,
ValidateAudience = false,
ValidateIssuer = true,
ValidIssuers = authHelper.ValidIssuers
});

if (!result.IsValid)
{
return new FailedCaller("Invalid JWT used");
}

if (!result.Claims.TryGetValue(JwtRegisteredClaimNames.Sub, out var subUrnObj) ||
subUrnObj is not string subUrn)
{
return new FailedCaller("JWT missing sub field");
}

// RFC 8141:
// NID - Namespace Identifier - here "dlcs" to have own namespace
// NSS - Namespace Specific String - currently we use "user" to inform that the next part is user (customer) id
const string dlcsUrnNamespace = "dlcs";
const string dlcsUrnUser = "user";

// In modern C#:
// if (subUrn.Split(':') is not [_, var nid, var nss, var id, ..])
// {
// return new FailedCaller("JWT sub in incorrect urn format");
// }

var parts = subUrn.Split(':');
if (parts.Length < 4)
{
return new FailedCaller("JWT sub in incorrect urn format");
}

var nid = parts[1];
var nss = parts[2];
var id = parts[3];

// Future integration: nid/nss/id can be used for granular permissions
// For now, we have 1 customer, the Customer Portal, so "hardcode" the
// verification.

// We only support the following subjects: urn:dlcs:user:<customerId>
if (!dlcsUrnNamespace.Equals(nid) || !dlcsUrnUser.Equals(nss))
{
return new FailedCaller("Unsupported/unauthorised token data");
}

if (!int.TryParse(id, out var customerId))
{
return new FailedCaller("Invalid customer id format");
}

var customer = await customerRepository.GetCustomer(customerId);
if (customer is null)
{
return new FailedCaller("Customer not found");
}

return new ApiCaller(customer.Keys.First(), customer);
}

/// <summary>
Expand All @@ -186,17 +296,35 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
}
}

/// <summary>
/// Can be <see cref="ApiCaller"/> or <see cref="FailedCaller"/>
/// </summary>
public interface IApiCaller
{
}

/// <summary>
/// Represents the credentials in the API request, the basic auth key:secret pair.
/// </summary>
public class ApiCaller
public class ApiCaller : IApiCaller
{
public ApiCaller(string key, string secret)
public ApiCaller(string key, Customer? customer)
{
Key = key;
Secret = secret;
Customer = customer;
}

public string Key { get; }

public Customer? Customer { get; }
}

public class FailedCaller : IApiCaller
{
public FailedCaller(string message)
{
Message = message;
}

public string Key { get; set; }
public string Secret { get; set; }
public string Message { get; }
}
22 changes: 22 additions & 0 deletions src/protagonist/API/Auth/JwtAuthHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using DLCS.Core.Settings;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace API.Auth;

public class JwtAuthHelper
{
public JwtAuthHelper(IOptions<DlcsSettings> dlcsSettings)
{
SigningCredentials = dlcsSettings.Value.JwtKey is {Length: > 0} key
? new(
new SymmetricSecurityKey(Convert.FromBase64String(key)),
SecurityAlgorithms.HmacSha256Signature)
: null;
ValidIssuers = dlcsSettings.Value.JwtValidIssuers.ToArray();
}

public SigningCredentials? SigningCredentials { get; }

public string[] ValidIssuers { get; }
}
Loading
Loading