Skip to content

Commit

Permalink
Support for bearer authentication
Browse files Browse the repository at this point in the history
Support for TOTP multi-factor auth
Added comments on new public types/members
Added specific package build for net48
  • Loading branch information
verifalia committed Nov 22, 2019
1 parent b094c91 commit 2e5df43
Show file tree
Hide file tree
Showing 10 changed files with 318 additions and 63 deletions.
9 changes: 8 additions & 1 deletion source/Verifalia.Api/IRestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ public interface IRestClient : IDisposable
{
IFlurlClient UnderlyingClient { get; }

Task<HttpResponseMessage> InvokeAsync(HttpMethod verb, string resource, Dictionary<string, string> queryParams = null, Dictionary<string, object> headers = null, HttpContent content = null, bool bufferResponseContent = true, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> InvokeAsync(HttpMethod verb,
string resource,
Dictionary<string, string> queryParams = null,
Dictionary<string, object> headers = null,
HttpContent content = null,
bool bufferResponseContent = true,
bool skipAuthentication = false,
CancellationToken cancellationToken = default);

// Json serialization

Expand Down
31 changes: 23 additions & 8 deletions source/Verifalia.Api/MultiplexedRestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,13 @@ private MultiplexedRestClient(IEnumerable<Uri> baseUris)
}


public async Task<HttpResponseMessage> InvokeAsync(HttpMethod verb, string resource, Dictionary<string, string> queryParams = null, Dictionary<string, object> headers = null, HttpContent content = null, bool bufferResponseContent = true, CancellationToken cancellationToken = default)
public async Task<HttpResponseMessage> InvokeAsync(HttpMethod verb, string resource, Dictionary<string, string> queryParams = null,
Dictionary<string, object> headers = null, HttpContent content = null, bool bufferResponseContent = true, bool skipAuthentication = false,
CancellationToken cancellationToken = default)
{
if (verb == null) throw new ArgumentNullException(nameof(verb));
if (resource == null) throw new ArgumentNullException(nameof(resource));

// Authenticates the underlying flurl client, if needed

await _authenticator.ProvideAuthenticationAsync(this, cancellationToken)
.ConfigureAwait(false);

// Performs a maximum of as many attempts as the number of configured base API endpoints, keeping track
// of the last used endpoint after each call, in order to try to distribute the load evenly across the
// available endpoints.
Expand All @@ -113,7 +110,15 @@ await _authenticator.ProvideAuthenticationAsync(this, cancellationToken)

for (var idxAttempt = 0; idxAttempt < _baseUrls.Length; idxAttempt++, _currentBaseUrlIdx++)
{
// Build the final url based on the base url and the specified path and query
// Authenticates the underlying flurl client, if needed

if (!skipAuthentication)
{
await _authenticator.AuthenticateAsync(this, cancellationToken)
.ConfigureAwait(false);
}

// Build the final url by combining the base url and the specified path and query

var baseUrl = _baseUrls[_currentBaseUrlIdx % _baseUrls.Length];
var finalUrl = new Url(baseUrl)
Expand Down Expand Up @@ -157,13 +162,23 @@ await _authenticator.ProvideAuthenticationAsync(this, cancellationToken)
if ((int)response.StatusCode >= 500 && (int)response.StatusCode <= 599)
{
errors.Add(finalUrl, new EndpointServerErrorException($"The API endpoint {baseUrl} returned a server error HTTP status code {response.StatusCode}."));
continue;
}

// If the request is unauthorized, give the authentication provider a chance to remediate (on a subsequent attempt)

if (response.StatusCode == HttpStatusCode.Unauthorized)
{
await _authenticator.HandleUnauthorizedRequestAsync(this, cancellationToken)
.ConfigureAwait(false);

errors.Add(finalUrl, new AuthorizationException("Can't authenticate to Verifalia using the provided credentials (will retry in the next attempt)."));
continue;
}

// Fails on the first occurrence of an HTTP 403 status code

if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
if (response.StatusCode == HttpStatusCode.Forbidden)
{
throw new AuthorizationException(response.ReasonPhrase);
}
Expand Down
15 changes: 13 additions & 2 deletions source/Verifalia.Api/Security/AppkeyAuthenticatorProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,14 @@
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;
using Verifalia.Api.Exceptions;

namespace Verifalia.Api.Security
{
internal class AppkeyAuthenticatorProvider : IAuthenticationProvider
/// <summary>
/// Allows to authenticate a REST client against the Verifalia API using a browser appKey.
/// </summary>
public class AppkeyAuthenticatorProvider : IAuthenticationProvider
{
private readonly string _appKey;

Expand All @@ -51,7 +55,8 @@ public AppkeyAuthenticatorProvider(string appKey)
_appKey = appKey;
}

public Task ProvideAuthenticationAsync(IRestClient restClient, CancellationToken cancellationToken = default)
/// <inheritdoc cref="IAuthenticationProvider.AuthenticateAsync(IRestClient, CancellationToken)"/>
public Task AuthenticateAsync(IRestClient restClient, CancellationToken cancellationToken = default)
{
restClient.UnderlyingClient.WithBasicAuth(_appKey, String.Empty);
#if HAS_TASK_COMPLETED_TASK
Expand All @@ -60,5 +65,11 @@ public Task ProvideAuthenticationAsync(IRestClient restClient, CancellationToken
return Task.FromResult((object) null);
#endif
}

/// <inheritdoc cref="IAuthenticationProvider.HandleUnauthorizedRequestAsync(IRestClient, CancellationToken)"/>
public Task HandleUnauthorizedRequestAsync(IRestClient restClient, CancellationToken cancellationToken)
{
throw new AuthorizationException("Can't authenticate to Verifalia using the provided appKey: please check your credentials and retry.");
}
}
}
197 changes: 165 additions & 32 deletions source/Verifalia.Api/Security/BearerAuthenticationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
* THE SOFTWARE.
*/

#if HAS_JWT_SUPPORT

using System.Linq;
using System;
using System.Net;
using System.Net.Http;
Expand All @@ -37,24 +40,38 @@
using System.Threading.Tasks;
using Flurl.Http;
using Newtonsoft.Json;
using System.IdentityModel.Tokens.Jwt;
using Verifalia.Api.Exceptions;

namespace Verifalia.Api.Security
{
internal class BearerAuthenticationProvider : IAuthenticationProvider
/// <summary>
/// Allows to authenticate a REST client against the Verifalia API using bearer authentication.
/// </summary>
public class BearerAuthenticationProvider : IAuthenticationProvider
{
internal class BearerAuthenticationResponseModel
{
[JsonProperty("accessToken")]
public string AccessToken { get; set; }
[JsonProperty("accessToken")] public string AccessToken { get; set; }
}

private const string JwtClaimMfaRequiredName = "verifalia:mfa";
private const string AuthorizationHttpHeaderName = "Authorization";
private const int MaxNoOfMfaAttempts = 5;

private readonly string _username;
private readonly string _password;

private string _cachedAccessToken;
private readonly ITotpTokenProvider _totpTokenProvider;

private JwtSecurityToken _securityToken = null;

public BearerAuthenticationProvider(string username, string password)
/// <summary>
/// Initializes a new bearer authentication provider for the Verifalia API, with the specified username and password.
/// </summary>
/// <param name="username">The username of the user.</param>
/// <param name="password">The password of the user.</param>
/// <param name="totpTokenProvider">An optional provider of TOTP tokens (needed if the user has multi-factor authentication enabled).</param>
public BearerAuthenticationProvider(string username, string password, ITotpTokenProvider totpTokenProvider = default)
{
if (String.IsNullOrEmpty(username))
{
Expand All @@ -70,42 +87,158 @@ public BearerAuthenticationProvider(string username, string password)

_username = username;
_password = password;
_totpTokenProvider = totpTokenProvider;
}

public async Task ProvideAuthenticationAsync(IRestClient restClient, CancellationToken cancellationToken = default)
public async Task AuthenticateAsync(IRestClient restClient, CancellationToken cancellationToken = default)
{
// TODO: Use the cached access token, if available
// TODO: How to check if the access token is expired?
if (restClient is null)
{
throw new ArgumentNullException(nameof(restClient));
}

restClient.UnderlyingClient.WithBasicAuth(_username, _password);
// Request a new security token to the Verifalia API, if one is not yet available

var content = restClient.Serialize(new
if (_securityToken == null)
{
username = _username,
password = _password
});

using var postedContent = new StringContent(content, Encoding.UTF8, WellKnownMimeContentTypes.ApplicationJson);
using var authResponse = await restClient.InvokeAsync(HttpMethod.Post,
"/auth/tokens",
content: postedContent,
cancellationToken: cancellationToken)
.ConfigureAwait(false);

if (authResponse.StatusCode == HttpStatusCode.OK)
{
var bearerAuthenticationResponse = await authResponse
.Content
.DeserializeAsync<BearerAuthenticationResponseModel>(restClient)
// Remove any eventual authorization header and request the token

restClient.UnderlyingClient.WithHeader(AuthorizationHttpHeaderName, null);

var content = restClient.Serialize(new
{
username = _username,
password = _password
});

using var postedContent =
new StringContent(content, Encoding.UTF8, WellKnownMimeContentTypes.ApplicationJson);
using var authResponse = await restClient.InvokeAsync(HttpMethod.Post,
"/auth/tokens",
content: postedContent,
// Avoid using the configured authentication provider - as auth tokens must be retrieved using HTTP basic auth
skipAuthentication: true,
cancellationToken: cancellationToken)
.ConfigureAwait(false);

if (authResponse.StatusCode == HttpStatusCode.OK)
{
var bearerAuthenticationResponse = await authResponse
.Content
.DeserializeAsync<BearerAuthenticationResponseModel>(restClient)
.ConfigureAwait(false);

// Handle the multi-factor auth (MFA) request, if needed

_securityToken = (JwtSecurityToken) new JwtSecurityTokenHandler()
.ReadToken(bearerAuthenticationResponse.AccessToken);

var mfaRequiredClaim = _securityToken
.Claims
.FirstOrDefault(claim => claim.Type == JwtClaimMfaRequiredName);

if (mfaRequiredClaim != null)
{
// Requests a new bearer token, passing the TOTP first

bearerAuthenticationResponse = await ProvideAdditionalAuthFactorAsync(restClient, cancellationToken)
.ConfigureAwait(false);

_securityToken = (JwtSecurityToken) new JwtSecurityTokenHandler()
.ReadToken(bearerAuthenticationResponse.AccessToken);
}
}
else
{
throw new AuthorizationException(
"Invalid credentials used while attempting to retrieve a bearer auth token.");
}
}

AddBearerAuth(restClient);
}

/// <inheritdoc cref="IAuthenticationProvider.AuthenticateAsync(IRestClient, CancellationToken)"/>
private async Task<BearerAuthenticationResponseModel> ProvideAdditionalAuthFactorAsync(IRestClient restClient, CancellationToken cancellationToken)
{
if (restClient is null)
{
throw new ArgumentNullException(nameof(restClient));
}

_cachedAccessToken = bearerAuthenticationResponse.AccessToken;
restClient.UnderlyingClient.WithHeader("Authorization", $"Bearer {_cachedAccessToken}");
if (_totpTokenProvider == null)
{
throw new AuthorizationException(
"A multi-factor authentication is required but no token provider has been provided.");
}
else

for (var idxAttempt = 0; idxAttempt < MaxNoOfMfaAttempts; idxAttempt++)
{
throw new AuthorizationException("Invalid credentials used while attempting to retrieve a bearer auth token.");
// Retrieve the one-time token from the configured device

var totp = await _totpTokenProvider
.ProvideTotpTokenAsync(cancellationToken)
.ConfigureAwait(false);

// Validates the provided token against the Verifalia API

using var postedContent = new StringContent(restClient.Serialize(new
{
passCode = totp
}), Encoding.UTF8, WellKnownMimeContentTypes.ApplicationJson);

try
{
AddBearerAuth(restClient);

using var authResponse = await restClient
.InvokeAsync(HttpMethod.Post,
"/auth/totp/verifications",
content: postedContent,
// Avoid using the configured authentication provider - as auth tokens must be retrieved using HTTP basic auth
skipAuthentication: true,
cancellationToken: cancellationToken)
.ConfigureAwait(false);

if (authResponse.StatusCode == HttpStatusCode.OK)
{
return await authResponse
.Content
.DeserializeAsync<BearerAuthenticationResponseModel>(restClient)
.ConfigureAwait(false);
}
}
catch (AuthorizationException)
{
// Having an authorization issue is allowed here, as we are working on an TOTP token validation attempt.
// We will re-throw an AuthorizationException below in the even all the configured TOTP validation attempts fail.
}
}

throw new AuthorizationException($"Invalid TOTP token provided after {MaxNoOfMfaAttempts} attempt(s): aborting the authentication.");
}

/// <inheritdoc cref="IAuthenticationProvider.HandleUnauthorizedRequestAsync(IRestClient, CancellationToken)"/>
public Task HandleUnauthorizedRequestAsync(IRestClient restClient, CancellationToken cancellationToken)
{
// Invalidates the stored security token, which will be acquired again in the next AuthenticateAsync() invocation

_securityToken = null;

// TODO: We may want to refresh the token, instead of forcing the library to re-acquire a new one

#if HAS_TASK_COMPLETED_TASK
return Task.CompletedTask;
#else
return Task.FromResult((object) null);
#endif
}

private void AddBearerAuth(IRestClient restClient)
{
restClient.UnderlyingClient.WithHeader(AuthorizationHttpHeaderName, $"Bearer {_securityToken.RawData}");
}
}
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@
using System.Security.Cryptography.X509Certificates;
using Flurl.Http;
using Flurl.Http.Configuration;
using Verifalia.Api.Exceptions;

namespace Verifalia.Api.Security
{
internal class ClientCertificateAuthenticationProvider : IAuthenticationProvider
/// <summary>
/// Allows to authenticate a REST client against the Verifalia API using an X509 client certificate.
/// </summary>
public class ClientCertificateAuthenticationProvider : IAuthenticationProvider
{
private readonly X509Certificate2 _certificate;

Expand Down Expand Up @@ -77,7 +81,8 @@ public ClientCertificateAuthenticationProvider(X509Certificate2 certificate)
_certificate = certificate;
}

public Task ProvideAuthenticationAsync(IRestClient restClient, CancellationToken cancellationToken = default)
/// <inheritdoc cref="IAuthenticationProvider.AuthenticateAsync(IRestClient, CancellationToken)"/>
public Task AuthenticateAsync(IRestClient restClient, CancellationToken cancellationToken = default)
{
if (restClient == null) throw new ArgumentNullException(nameof(restClient));

Expand All @@ -87,6 +92,12 @@ public Task ProvideAuthenticationAsync(IRestClient restClient, CancellationToken

return Task.CompletedTask;
}

/// <inheritdoc cref="IAuthenticationProvider.HandleUnauthorizedRequestAsync(IRestClient, CancellationToken)"/>
public Task HandleUnauthorizedRequestAsync(IRestClient restClient, CancellationToken cancellationToken)
{
throw new AuthorizationException("Can't authenticate to Verifalia using the provided X509 certificate: please check your credentials and retry.");
}
}
}

Expand Down
Loading

0 comments on commit 2e5df43

Please sign in to comment.