Skip to content

Commit

Permalink
Merge pull request #66 from mdsol/develop
Browse files Browse the repository at this point in the history
Release of v5.0.0
  • Loading branch information
Herry Kurniawan authored Aug 7, 2020
2 parents 512544b + 50dfbd9 commit 843701f
Show file tree
Hide file tree
Showing 33 changed files with 686 additions and 159 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changes in Medidata.MAuth

## v5.0.0
- **[Core]** Added normalization of Uri AbsolutePath.
- **[Core]** Added unescape step in query_string encoding to remove `double encoding`.
- **[Core]** Replace `DisableV1`option with `SignVersions` option and change the default signing to `MAuthVersion.MWS` only.
- **[Core]** Added parsing code to test with mauth-protocol-test-suite.
- **[Core]** Fixed bug in sorting of query parameters.

## v4.0.2
- **[AspNetCore]** Update aspnetcore version to aspnetcore2.1 LTS.
- **[Core]** Fallback to V1 protocol when V2 athentication fails.
Expand Down
25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Contributing

## General Information
* Clone this repo in your workspace. Checkout latest `develop` branch.
* Make new changes or updates into `feature/bugfix` branch.
* Make sure to add unit tests for it so that there is no breaking changes.
* Commit and push your branch to compare and create PR against latest `develop` branch.

## Running Tests
To run tests, go the folder `mauth-client-dotnet\tests\Medidata.MAuth.Tests`
Next, run the tests as:

```
dotnet test --filter "Category!=ProtocolTestSuite"
```

## Running mauth-protocol-test-suite
To run the mauth-protocol-test-suite clone the latest suite onto your machine and place it in the same parent directory as this repo (or supply the ENV var
`TEST_SUITE_PATH` with the path to the test suite relative to this repo).
Then navigate to :`mauth-client-dotnet\tests\Medidata.MAuth.Tests`
And, run the tests as:

```
dotnet test --filter "Category=ProtocolTestSuite"
```
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,8 @@ public async Task<HttpResponseMessage> SignAndSendRequest(HttpRequestMessage req
// The following can be either a path to the key file or the contents of the file itself
PrivateKey = "ClientPrivateKey.pem",

// when ready to disable authentication of V1 protocol else default is false
// signs with both V1 and V2.
DisableV1 = true
// Enumerations of signing protocols, if not provided defaults to `MAuthVersion.MWS`for sign-in.
SignVersions = MAuthVersion.MWS | MAuthVersion.MWSV2
});

using (var client = new HttpClient(signingHandler))
Expand All @@ -125,10 +124,12 @@ public async Task<HttpResponseMessage> SignAndSendRequest(HttpRequestMessage req
}
}
```
With the release of support for MAuth V2 protocol, by default MAuth request signs with both V1 and V2 protocol.
Also by default, `DisableV1` option is set to false (if not included). When we are ready to
disable all the V1 request, then we need to include this disable option as : `DisableV1 = true`.
Signing with V2 protocol supports query string.
The `SignVersions` parameter can be used to specify which protocol version to sign outgoing requests. Like as:
`SignVersions = MAuthVersion.MWS`: signs with `MWS` protocol only.
`SignVersions = MAuthVersion.MWS | MAuthVersion.MWSV2` : signs with both `MWS` and `MWSV2` protocol.
If not supplied, it sign by `MWS` protocol by default.

Signing with `MWSV2` protocol supports query string.

The example above is creating a new instance of a `HttpClient` with the handler responsible for signing the
requests and sends the request to its designation. Finally it returns the response from the remote server.
Expand All @@ -139,7 +140,7 @@ The `MAuthSigningOptions` has the following properties to determine the required
| ---- | ----------- |
| **ApplicationUuid** | Determines the unique identifier of the client application used for the MAuth service authentication requests. This uuid needs to be registered with the MAuth Server in order for the authenticating server application to be able to authenticate the signed request. |
| **PrivateKey** | Determines the RSA private key of the client for signing a request. This key must be in a PEM ASN.1 format. The value of this property can be set as a valid path to a readable key file as well. |
| **DisableV1** | Determines the boolean value which controls whether to disable the signing requests with V1 protocol or not. If not supplied, this value is `false`. |
| **SignVersions** | (optional) Enumerations of MAuth protocol versions to sign requests. If not supplied, defaults to `MWS`.

### Authenticating Incoming Requests with the OWIN and ASP.NET Core Middlewares

Expand Down Expand Up @@ -330,6 +331,11 @@ On the .NET Framework side (WebAPI, Owin, Core) we are using the latest version
[BouncyCastle](https://github.com/bcgit/bc-csharp) library; on the .NET Standard side (Core, AspNetCore) we are using
the portable fork of the [BouncyCastle](https://github.com/onovotny/BouncyCastle-PCL) library.

##### What are the major changes in the 5.0.0 version?
In this version we have removed the property `DisableV1` from `MAuthSigningOptions`. Instead, we have added new option as
`SignVersions` in `MAuthSigningOptions` which takes enumeration values of MAuth protcol versions `MWS` and/ or `MWSV2` protocol.
If this option is not provided, then it will sign in by `MWS` protocol as default.

##### What are the major changes in the 4.0.0 version?

In this version we have added support for V2 protocol which uses `MCC-Authentication` as MAuthHeader and `MCC-Time` as
Expand Down
2 changes: 1 addition & 1 deletion build/build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Write-Host "Running unit tests..." -ForegroundColor Cyan

Push-Location -Path .\tests\Medidata.MAuth.Tests

dotnet test
dotnet test --filter "Category!=ProtocolTestSuite"

Pop-Location

Expand Down
18 changes: 9 additions & 9 deletions src/Medidata.MAuth.AspNetCore/MAuthMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ namespace Medidata.MAuth.AspNetCore
/// </summary>
internal class MAuthMiddleware
{
private readonly MAuthMiddlewareOptions options;
private readonly MAuthAuthenticator authenticator;
private readonly RequestDelegate next;
private readonly MAuthMiddlewareOptions _options;
private readonly MAuthAuthenticator _authenticator;
private readonly RequestDelegate _next;

/// <summary>
/// Creates a new <see cref="MAuthMiddleware"/>
Expand All @@ -24,11 +24,11 @@ internal class MAuthMiddleware
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/> representing the factory that used to create logger instances.</param>
public MAuthMiddleware(RequestDelegate next, MAuthMiddlewareOptions options, ILoggerFactory loggerFactory)
{
this.next = next;
this.options = options;
_next = next;
_options = options;
loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
ILogger logger = loggerFactory.CreateLogger<MAuthMiddleware>();
this.authenticator = new MAuthAuthenticator(options, logger);
_authenticator = new MAuthAuthenticator(options, logger);
}

/// <summary>
Expand All @@ -40,16 +40,16 @@ public async Task Invoke(HttpContext context)
{
context.Request.EnableBuffering();

if (!options.Bypass(context.Request) &&
!await context.TryAuthenticate(authenticator, options.HideExceptionsAndReturnUnauthorized).ConfigureAwait(false))
if (!_options.Bypass(context.Request) &&
!await context.TryAuthenticate(_authenticator, _options.HideExceptionsAndReturnUnauthorized).ConfigureAwait(false))
{
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}

context.Request.Body.Rewind();

await next.Invoke(context).ConfigureAwait(false);
await _next.Invoke(context).ConfigureAwait(false);
}
}
}
4 changes: 4 additions & 0 deletions src/Medidata.MAuth.Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,9 @@ internal static class Constants
public static readonly string MAuthTimeHeaderKeyV2 = "MCC-Time";

public static readonly string MAuthTokenRequestPath = "/mauth/v1/security_tokens/";

public static readonly Regex LowerCaseHexPattern = new Regex("%[a-f0-9]{2}", RegexOptions.Compiled);

public static readonly Regex SlashPattern = new Regex("//+", RegexOptions.Compiled);
}
}
55 changes: 29 additions & 26 deletions src/Medidata.MAuth.Core/MAuthAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ namespace Medidata.MAuth.Core
{
internal class MAuthAuthenticator
{
private readonly MAuthOptionsBase options;
private readonly IMemoryCache cache = new MemoryCache(new MemoryCacheOptions());
private readonly ILogger logger;
private readonly MAuthOptionsBase _options;
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
private readonly ILogger _logger;

public Guid ApplicationUuid => options.ApplicationUuid;
public Guid ApplicationUuid => _options.ApplicationUuid;

public MAuthAuthenticator(MAuthOptionsBase options, ILogger logger)
{
Expand All @@ -29,8 +29,8 @@ public MAuthAuthenticator(MAuthOptionsBase options, ILogger logger)
if (string.IsNullOrWhiteSpace(options.PrivateKey))
throw new ArgumentNullException(nameof(options.PrivateKey));

this.options = options;
this.logger = logger;
_options = options;
_logger = logger;
}

/// <summary>
Expand All @@ -42,58 +42,61 @@ public async Task<bool> AuthenticateRequest(HttpRequestMessage request)
{
try
{
logger.LogInformation("Initiating Authentication of the request.");
var version = request.GetAuthHeaderValue().GetVersionFromAuthenticationHeader();
_logger.LogInformation("Initiating Authentication of the request.");

if (options.DisableV1 && version == MAuthVersion.MWS)
var authHeader = request.GetAuthHeaderValue();
var version = authHeader.GetVersionFromAuthenticationHeader();
var parsedHeader = authHeader.ParseAuthenticationHeader();

if (_options.DisableV1 && version == MAuthVersion.MWS)
throw new InvalidVersionException($"Authentication with {version} version is disabled.");

var authenticated = await Authenticate(request, version).ConfigureAwait(false);
if (!authenticated && version == MAuthVersion.MWSV2 && !options.DisableV1)
var authenticated = await Authenticate(request, version, parsedHeader.Uuid).ConfigureAwait(false);
if (!authenticated && version == MAuthVersion.MWSV2 && !_options.DisableV1)
{
// fall back to V1 authentication
authenticated = await Authenticate(request, MAuthVersion.MWS).ConfigureAwait(false);
logger.LogWarning("Completed successful authentication attempt after fallback to V1");
authenticated = await Authenticate(request, MAuthVersion.MWS, parsedHeader.Uuid).ConfigureAwait(false);
_logger.LogWarning("Completed successful authentication attempt after fallback to V1");
}
return authenticated;
}
catch (ArgumentException ex)
{
logger.LogError(ex, "Unable to authenticate due to invalid MAuth authentication headers.");
_logger.LogError(ex, "Unable to authenticate due to invalid MAuth authentication headers.");
throw new AuthenticationException("The request has invalid MAuth authentication headers.", ex);
}
catch (RetriedRequestException ex)
{
logger.LogError(ex, "Unable to query the application information from MAuth server.");
_logger.LogError(ex, "Unable to query the application information from MAuth server.");
throw new AuthenticationException(
"Could not query the application information for the application from the MAuth server.", ex);
}
catch (InvalidCipherTextException ex)
{
logger.LogWarning(ex, "Unable to authenticate due to invalid payload information.");
_logger.LogWarning(ex, "Unable to authenticate due to invalid payload information.");
throw new AuthenticationException(
"The request verification failed due to an invalid payload information.", ex);
}
catch (InvalidVersionException ex)
{
logger.LogError(ex, "Unable to authenticate due to invalid version.");
_logger.LogError(ex, "Unable to authenticate due to invalid version.");
throw new InvalidVersionException(ex.Message, ex);
}
catch (Exception ex)
{
logger.LogError(ex, "Unable to authenticate due to unexpected error.");
_logger.LogError(ex, "Unable to authenticate due to unexpected error.");
throw new AuthenticationException(
"An unexpected error occured during authentication. Please see the inner exception for details.",
ex
);
}
}

private async Task<bool> Authenticate(HttpRequestMessage request, MAuthVersion version)
private async Task<bool> Authenticate(HttpRequestMessage request, MAuthVersion version, Guid signedAppUuid)
{
var logMessage = "Mauth-client attempting to authenticate request from app with mauth app uuid" +
$" {options.ApplicationUuid} using version {version}";
logger.LogInformation(logMessage);
var logMessage = "Mauth-client attempting to authenticate request from app with mauth app uuid " +
$"{signedAppUuid} to app with mauth app uuid {_options.ApplicationUuid} using version {version}";
_logger.LogInformation(logMessage);

var mAuthCore = MAuthCoreFactory.Instantiate(version);
var authInfo = GetAuthenticationInfo(request, mAuthCore);
Expand All @@ -104,13 +107,13 @@ private async Task<bool> Authenticate(HttpRequestMessage request, MAuthVersion v
}

private Task<ApplicationInfo> GetApplicationInfo(Guid applicationUuid) =>
cache.GetOrCreateAsync(applicationUuid, async entry =>
_cache.GetOrCreateAsync(applicationUuid, async entry =>
{
var retrier = new MAuthRequestRetrier(options);
var retrier = new MAuthRequestRetrier(_options);
var response = await retrier.GetSuccessfulResponse(
applicationUuid,
CreateRequest,
requestAttempts: (int)options.MAuthServiceRetryPolicy + 1
requestAttempts: (int)_options.MAuthServiceRetryPolicy + 1
).ConfigureAwait(false);

var result = await response.Content.FromResponse().ConfigureAwait(false);
Expand Down Expand Up @@ -157,7 +160,7 @@ internal static PayloadAuthenticationInfo GetAuthenticationInfo(HttpRequestMessa
}

private HttpRequestMessage CreateRequest(Guid applicationUuid) =>
new HttpRequestMessage(HttpMethod.Get, new Uri(options.MAuthServiceUrl,
new HttpRequestMessage(HttpMethod.Get, new Uri(_options.MAuthServiceUrl,
$"{Constants.MAuthTokenRequestPath}{applicationUuid.ToHyphenString()}.json"));
}
}
54 changes: 45 additions & 9 deletions src/Medidata.MAuth.Core/MAuthCoreExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,17 +184,53 @@ public static byte[] Concat(this byte[][] values)
/// <returns>EncodedQueryParameter string.</returns>
public static string BuildEncodedQueryParams(this string queryString)
{
var encodedQueryStrings = new List<string>();
if (string.IsNullOrEmpty(queryString))
return string.Empty;

var queryArray = queryString.Split('&');
Array.Sort(queryArray, StringComparer.Ordinal);
Array.ForEach(queryArray, x =>
var unescapedKeysAndValues = new KeyValuePair<string, string>[queryArray.Length];

// unescaping
for (int i = 0; i < queryArray.Length; i++)
{
var keyValue = x.Split('=');
var escapedKey = Uri.EscapeDataString(keyValue[0]);
var escapedValue = Uri.EscapeDataString(keyValue[1]);
encodedQueryStrings.Add($"{escapedKey}={escapedValue}");
});
return string.Join("&", encodedQueryStrings);
var keyValue = queryArray[i].Split('=');
unescapedKeysAndValues[i] = new KeyValuePair<string, string>(
Uri.UnescapeDataString(keyValue[0]), Uri.UnescapeDataString(keyValue[1]));
}

// sorting and escaping
var escapedKeyValues = unescapedKeysAndValues
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.ThenBy(kv => kv.Value, StringComparer.Ordinal)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}");

// Above encoding converts space as `%20` and `+` as `%2B`
// But space and `+` both needs to be converted as `%20` as per
// reference https://github.com/mdsol/mauth-client-ruby/blob/v6.0.0/lib/mauth/request_and_response.rb#L113
// so this convert `%2B` into `%20` to match encodedqueryparams to that of other languages.
return string.Join("&", escapedKeyValues).Replace("%2B", "%20");
}

/// <summary>
/// Normalizes the UriPath
/// </summary>
/// <param name="path"></param>
/// <returns>Normalized Uri Resource Path</returns>
public static string NormalizeUriPath(this string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;

// Normalize percent encoding to uppercase i.e. %cf%80 => %CF%80
var matches = Constants.LowerCaseHexPattern.Matches(path);
var normalizedPath = new StringBuilder(path);
foreach(var item in matches)
{
normalizedPath.Replace(item.ToString(), item.ToString().ToUpper());
}

// Replaces multiple slashes into single "/"
return Constants.SlashPattern.Replace(normalizedPath.ToString(), "/");
}
}
}
8 changes: 5 additions & 3 deletions src/Medidata.MAuth.Core/MAuthCoreV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,18 @@ public bool Verify(byte[] signedData, byte[] signature, string publicKey)
public async Task<byte[]> GetSignature(HttpRequestMessage request, AuthenticationInfo authInfo)
{
var encodedHttpVerb = request.Method.Method.ToBytes();
var encodedResourceUriPath = request.RequestUri.AbsolutePath.ToBytes();
var encodedResourceUriPath = request.RequestUri.AbsolutePath.NormalizeUriPath().ToBytes();
var encodedAppUUid = authInfo.ApplicationUuid.ToHyphenString().ToBytes();

var requestBody = request.Content != null ?
await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : new byte[] { };
var requestBodyDigest = requestBody.AsSHA512Hash();

var encodedCurrentSecondsSinceEpoch = authInfo.SignedTime.ToUnixTimeSeconds().ToString().ToBytes();
var encodedQueryParams = !string.IsNullOrEmpty(request.RequestUri.Query) ?
request.RequestUri.Query.Replace("?", "").BuildEncodedQueryParams().ToBytes() : new byte[] { };
var queryString = request.RequestUri.Query;
var encodedQueryParams = !string.IsNullOrEmpty(queryString)
? queryString.Substring(1).BuildEncodedQueryParams().ToBytes()
: new byte[] { };

return new byte[][]
{
Expand Down
Loading

0 comments on commit 843701f

Please sign in to comment.