Skip to content

Commit

Permalink
Add support for ApiKey V3 in authentication flow (#5358)
Browse files Browse the repository at this point in the history
* Add support for ApiKey V3 in authentication flow

* PR comments

* PR comments
  • Loading branch information
skofman1 authored Jan 27, 2018
1 parent cb773cf commit 58398c9
Show file tree
Hide file tree
Showing 9 changed files with 334 additions and 18 deletions.
12 changes: 11 additions & 1 deletion src/NuGetGallery.Core/CredentialTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public static class ApiKey
public const string Prefix = "apikey.";
public const string V1 = Prefix + "v1";
public const string V2 = Prefix + "v2";
public const string V3 = Prefix + "v3";
public const string V4 = Prefix + "v4";
public const string VerifyV1 = Prefix + "verify.v1";
}
Expand All @@ -48,7 +49,16 @@ public static bool IsPackageVerificationApiKey(string type)
return type.Equals(ApiKey.VerifyV1, StringComparison.OrdinalIgnoreCase);
}

internal static IReadOnlyList<string> SupportedCredentialTypes = new List<string> { Password.Sha1, Password.Pbkdf2, Password.V3, ApiKey.V1, ApiKey.V2, ApiKey.V4 };
internal static IReadOnlyList<string> SupportedCredentialTypes = new List<string>
{
Password.Sha1,
Password.Pbkdf2,
Password.V3,
ApiKey.V1,
ApiKey.V2,
ApiKey.V3,
ApiKey.V4
};

/// <summary>
/// Determines whether a credential is supported (internal or from the UI). For forward compatibility,
Expand Down
105 changes: 105 additions & 0 deletions src/NuGetGallery/Infrastructure/Authentication/ApiKeyV3.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;

namespace NuGetGallery.Infrastructure.Authentication
{
public class ApiKeyV3
{
private const int IdPartLength = 10;
internal const int IdAndPasswordHashedLength = 94;

/// <summary>
/// Plaintext format of the ApiKey
/// </summary>
public string PlaintextApiKey { get; private set; }

/// <summary>
/// Hashed format of the ApiKey. Will be set only if CreateFromV1V2ApiKey method was used.
/// </summary>
public string HashedApiKey { get; private set; }

/// <summary>
/// Id part of the ApiKey
/// </summary>
public string IdPart { get; private set; }

/// <summary>
/// Password part of the ApiKey (plaintext)
/// </summary>
public string PasswordPart { get; private set; }

private ApiKeyV3()
{
}

/// <summary>
/// Creates an ApiKeyV3 from an APIKey V1/V2 format (GUID).
/// </summary>
public static ApiKeyV3 CreateFromV1V2ApiKey(string plaintextApiKey)
{
// Since V1/V2/V3 have the same format (Guid), we can use the same parse method
if (!TryParse(plaintextApiKey, out ApiKeyV3 apiKeyV3))
{
throw new ArgumentException("Invalid format for ApiKey V1/V2");
}

apiKeyV3.HashedApiKey = apiKeyV3.IdPart + V3Hasher.GenerateHash(apiKeyV3.PasswordPart);

return apiKeyV3;
}

/// <summary>
/// Parses the provided string and creates an ApiKeyV3 if it's successful.
/// The plaintext string is expected to be a GUID.
/// </summary>
public static bool TryParse(string plaintextApiKey, out ApiKeyV3 apiKey)
{
apiKey = new ApiKeyV3();
return apiKey.TryParseInternal(plaintextApiKey);
}

/// <summary>
/// Verified this ApiKey with provided hashed ApiKey.
/// </summary>
public bool Verify(string hashedApiKey)
{
if (string.IsNullOrWhiteSpace(hashedApiKey) || hashedApiKey.Length != IdAndPasswordHashedLength)
{
return false;
}

string hashedApiKeyIdPart = hashedApiKey.Substring(0, IdPartLength);
string hashedApiKeyPasswordPart = hashedApiKey.Substring(IdPartLength);

if (!string.Equals(IdPart, Normalize(hashedApiKeyIdPart)))
{
return false;
}

return V3Hasher.VerifyHash(hashedApiKeyPasswordPart, PasswordPart);
}

private bool TryParseInternal(string plaintextApiKey)
{
if (!Guid.TryParse(plaintextApiKey, out Guid apiKeyGuid))
{
return false;
}

var apiKeyString = apiKeyGuid.ToString("N");

IdPart = Normalize(apiKeyString.Substring(0, IdPartLength));
PasswordPart = apiKeyString.Substring(IdPartLength);
PlaintextApiKey = Normalize(apiKeyGuid.ToString());

return true;
}

private static string Normalize(string input)
{
return input.ToLowerInvariant();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public bool ValidatePasswordCredential(Credential credential, string providedPas

public IList<Credential> GetValidCredentialsForApiKey(IQueryable<Credential> allCredentials, string providedApiKey)
{
List<Credential> results;
var results = new List<Credential>();

if (ApiKeyV4.TryParse(providedApiKey, out ApiKeyV4 apiKeyV4))
{
Expand All @@ -43,8 +43,34 @@ public IList<Credential> GetValidCredentialsForApiKey(IQueryable<Credential> all
}
else
{
results = allCredentials.Where(c => c.Type.StartsWith(CredentialTypes.ApiKey.Prefix) &&
c.Value == providedApiKey).ToList();
// Try to authenticate as APIKey V1/V2/V3/Verify
if (ApiKeyV3.TryParse(providedApiKey, out var v3ApiKey))
{
results = allCredentials.Where(c => c.Type.StartsWith(CredentialTypes.ApiKey.Prefix) &&
(c.Value == providedApiKey || c.Value.StartsWith(v3ApiKey.IdPart))).ToList();

results = results.Where(credential =>
{
switch (credential.Type)
{
case CredentialTypes.ApiKey.V1:
case CredentialTypes.ApiKey.V2:
case CredentialTypes.ApiKey.VerifyV1:
{
return credential.Value == providedApiKey;
}
case CredentialTypes.ApiKey.V3:
{
return v3ApiKey.Verify(credential.Value);
}

default:
{
return false;
}
}
}).ToList();
}
}

return results;
Expand Down
1 change: 1 addition & 0 deletions src/NuGetGallery/NuGetGallery.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@
<Compile Include="Helpers\PermissionsHelpers.cs" />
<Compile Include="Helpers\RegexEx.cs" />
<Compile Include="Helpers\RouteUrlTemplate.cs" />
<Compile Include="Infrastructure\Authentication\ApiKeyV3.cs" />
<Compile Include="Infrastructure\Authentication\ApiKeyV4.cs" />
<Compile Include="Infrastructure\Authentication\Base32Encoder.cs" />
<Compile Include="Migrations\201711082145351_AddAccountDelete.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ public async Task GivenAnOrganizationApiKeyCredential_ItReturnsNull()
[Theory]
[InlineData(CredentialTypes.ApiKey.V1)]
[InlineData(CredentialTypes.ApiKey.V2)]
[InlineData(CredentialTypes.ApiKey.V3)]
[InlineData(CredentialTypes.ApiKey.V4)]
[InlineData(CredentialTypes.ApiKey.VerifyV1)]
public async Task GivenMatchingApiKeyCredential_ItReturnsTheUserAndMatchingCredential(string apiKeyType)
Expand All @@ -341,9 +342,10 @@ public async Task GivenMatchingApiKeyCredential_ItReturnsTheUserAndMatchingCrede
var cred = _fakes.User.Credentials.Single(
c => string.Equals(c.Type, apiKeyType, StringComparison.OrdinalIgnoreCase));

string plaintextValue = GetPlaintextApiKey(apiKeyType, cred);

// Act
// Create a new credential to verify that it's a value-based lookup!
var result = await _authenticationService.Authenticate(cred.Value);
var result = await _authenticationService.Authenticate(plaintextValue);

// Assert
Assert.NotNull(result);
Expand All @@ -354,6 +356,7 @@ public async Task GivenMatchingApiKeyCredential_ItReturnsTheUserAndMatchingCrede
[Theory]
[InlineData(CredentialTypes.ApiKey.V1)]
[InlineData(CredentialTypes.ApiKey.V2)]
[InlineData(CredentialTypes.ApiKey.V3)]
[InlineData(CredentialTypes.ApiKey.V4)]
[InlineData(CredentialTypes.ApiKey.VerifyV1)]
public async Task GivenMatchingApiKeyCredential_ItWritesCredentialLastUsed(string apiKeyType)
Expand All @@ -367,11 +370,10 @@ public async Task GivenMatchingApiKeyCredential_ItWritesCredentialLastUsed(strin

Assert.False(cred.LastUsed.HasValue);

string plaintextValue = GetPlaintextApiKey(apiKeyType, cred);

// Act
// Create a new credential to verify that it's a value-based lookup!
var result =
await
_authenticationService.Authenticate(cred.Value);
var result = await _authenticationService.Authenticate(plaintextValue);

// Assert
Assert.NotNull(result);
Expand All @@ -382,6 +384,7 @@ public async Task GivenMatchingApiKeyCredential_ItWritesCredentialLastUsed(strin
[Theory]
[InlineData(CredentialTypes.ApiKey.V1)]
[InlineData(CredentialTypes.ApiKey.V2)]
[InlineData(CredentialTypes.ApiKey.V3)]
[InlineData(CredentialTypes.ApiKey.V4)]
[InlineData(CredentialTypes.ApiKey.VerifyV1)]
public async Task GivenExpiredMatchingApiKeyCredential_ItReturnsNull(string apiKeyType)
Expand All @@ -392,9 +395,10 @@ public async Task GivenExpiredMatchingApiKeyCredential_ItReturnsNull(string apiK

cred.Expires = DateTime.UtcNow.AddDays(-1);

string plaintextValue = GetPlaintextApiKey(apiKeyType, cred);

// Act
// Create a new credential to verify that it's a value-based lookup!
var result = await _authenticationService.Authenticate(cred.Value);
var result = await _authenticationService.Authenticate(plaintextValue);

// Assert
Assert.Null(result);
Expand All @@ -403,6 +407,7 @@ public async Task GivenExpiredMatchingApiKeyCredential_ItReturnsNull(string apiK
[Theory]
[InlineData(CredentialTypes.ApiKey.V1, true)]
[InlineData(CredentialTypes.ApiKey.V2, false)]
[InlineData(CredentialTypes.ApiKey.V3, false)]
[InlineData(CredentialTypes.ApiKey.V4, false)]
public async Task GivenMatchingApiKeyCredentialThatWasLastUsedTooLongAgo_ItReturnsNullAndExpiresTheApiKeyAndWritesAuditRecord(string apiKeyType, bool shouldExpire)
{
Expand All @@ -416,7 +421,7 @@ public async Task GivenMatchingApiKeyCredentialThatWasLastUsedTooLongAgo_ItRetur
cred.LastUsed = DateTime.UtcNow.AddDays(-20);

var service = Get<AuthenticationService>();
var plaintextValue = apiKeyType == CredentialTypes.ApiKey.V4 ? _fakes.ApiKeyV4PlaintextValue : cred.Value;
string plaintextValue = GetPlaintextApiKey(apiKeyType, cred);

// Act
var result = await service.Authenticate(plaintextValue);
Expand All @@ -441,9 +446,39 @@ public async Task GivenMatchingApiKeyCredentialThatWasLastUsedTooLongAgo_ItRetur
}
}

[Fact]
public async Task GivenMatchingV3ApiKeyWithNoScopesThatWasLastUsedTooLongAgo_ItReturnsNullAndExpiresTheApiKeyAndWritesAuditRecord()
{
// Arrange
var configurationService = GetConfigurationService();
configurationService.Current.ExpirationInDaysForApiKeyV1 = 10;

var cred = _fakes.User.Credentials.Single(c => string.Equals(c.Type, CredentialTypes.ApiKey.V3, StringComparison.OrdinalIgnoreCase));

// Clear the scopes list, to simulate a V3 ApiKey that was generated from a V1 ApiKey
cred.Scopes = new List<Scope>();

// credential was last used < allowed last used
cred.LastUsed = DateTime.UtcNow.AddDays(-20);

var service = Get<AuthenticationService>();
string plaintextValue = _fakes.ApiKeyV3PlaintextValue;

// Act
var result = await service.Authenticate(plaintextValue);

// Assert
Assert.Null(result);
Assert.True(cred.HasExpired);
Assert.True(service.Auditing.WroteRecord<UserAuditRecord>(ar =>
ar.Action == AuditedUserAction.ExpireCredential &&
ar.Username == _fakes.User.Username));
}

[Theory]
[InlineData(CredentialTypes.ApiKey.V1)]
[InlineData(CredentialTypes.ApiKey.V2)]
[InlineData(CredentialTypes.ApiKey.V3)]
[InlineData(CredentialTypes.ApiKey.V4)]
public async Task GivenMultipleMatchingCredentials_ItThrows(string apiKeyType)
{
Expand All @@ -457,7 +492,7 @@ public async Task GivenMultipleMatchingCredentials_ItThrows(string apiKeyType)
creds.Add(cred1);
creds.Add(cred2);

var plaintextValue = apiKeyType == CredentialTypes.ApiKey.V4 ? _fakes.ApiKeyV4PlaintextValue : cred1.Value;
var plaintextValue = GetPlaintextApiKey(apiKeyType, cred1);

// Act
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await _authenticationService.Authenticate(plaintextValue));
Expand Down Expand Up @@ -485,6 +520,25 @@ public async Task GivenInvalidApiKeyCredential_ItReturnsNullAndWritesAnAuditReco
ar.Action == AuditedAuthenticatedOperationAction.FailedLoginNoSuchUser &&
string.IsNullOrEmpty(ar.UsernameOrEmail)));
}

private string GetPlaintextApiKey(string apiKeyType, Credential cred)
{
string plaintextValue;
if (apiKeyType == CredentialTypes.ApiKey.V3)
{
plaintextValue = _fakes.ApiKeyV3PlaintextValue;
}
else if (apiKeyType == CredentialTypes.ApiKey.V4)
{
plaintextValue = _fakes.ApiKeyV4PlaintextValue;
}
else
{
plaintextValue = cred.Value;
}

return plaintextValue;
}
}

public class TheAuthenticateMethod : TestContainer
Expand Down
20 changes: 16 additions & 4 deletions tests/NuGetGallery.Facts/Authentication/TestCredentialHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,19 @@ public static Credential CreateSha1Password(string plaintextPassword)

public static Credential CreateV1ApiKey(Guid apiKey, TimeSpan? expiration)
{
return CreateApiKey(CredentialTypes.ApiKey.V1, apiKey.ToString(), expiration);
return CreateApiKey(CredentialTypes.ApiKey.V1, GuidToApiKey(apiKey), expiration);
}

public static Credential CreateV2ApiKey(Guid apiKey, TimeSpan? expiration)
{
return CreateApiKey(CredentialTypes.ApiKey.V2, apiKey.ToString(), expiration);
return CreateApiKey(CredentialTypes.ApiKey.V2, GuidToApiKey(apiKey), expiration);
}

public static Credential CreateV3ApiKey(Guid apiKey, TimeSpan? expiration)
{
var v3ApiKey = ApiKeyV3.CreateFromV1V2ApiKey(GuidToApiKey(apiKey));

return CreateApiKey(CredentialTypes.ApiKey.V3, v3ApiKey.HashedApiKey, expiration);
}

public static Credential CreateV4ApiKey(TimeSpan? expiration, out string plaintextApiKey)
Expand Down Expand Up @@ -66,7 +73,7 @@ public static Credential WithDefaultScopes(this Credential credential)

public static Credential CreateV2VerificationApiKey(Guid apiKey)
{
return CreateApiKey(CredentialTypes.ApiKey.VerifyV1, apiKey.ToString(), TimeSpan.FromDays(1));
return CreateApiKey(CredentialTypes.ApiKey.VerifyV1, GuidToApiKey(apiKey), TimeSpan.FromDays(1));
}

public static Credential CreateExternalCredential(string value, string tenantId = null)
Expand All @@ -76,7 +83,12 @@ public static Credential CreateExternalCredential(string value, string tenantId

internal static Credential CreateApiKey(string type, string apiKey, TimeSpan? expiration)
{
return new Credential(type, apiKey.ToLowerInvariant(), expiration: expiration);
return new Credential(type, apiKey, expiration: expiration);
}

private static string GuidToApiKey(Guid guid)
{
return guid.ToString().ToLowerInvariant();
}
}
}
Loading

0 comments on commit 58398c9

Please sign in to comment.