Skip to content

Commit

Permalink
Merge pull request #900 from dlcs/feature/ICS-31/jwt_customer_auth
Browse files Browse the repository at this point in the history
Add JWT-based Auth option for API requests
  • Loading branch information
p-kaczynski authored Sep 5, 2024
2 parents 312f03d + 080bf9c commit 5397f96
Show file tree
Hide file tree
Showing 17 changed files with 227 additions and 63 deletions.
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

0 comments on commit 5397f96

Please sign in to comment.