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

Update External Account Linking Flow #5338

Merged
merged 13 commits into from
Jan 30, 2018
2 changes: 1 addition & 1 deletion src/NuGetGallery.Core/Auditing/CredentialAuditRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public CredentialAuditRecord(Credential credential, bool removed)
Identity = credential.Identity;

// Track the value for credentials that are definitely revocable (API Key, etc.) and have been removed
if (removed && !CredentialTypes.IsPassword(credential.Type))
if (removed && !credential.IsPassword())
{
Value = credential.Value;
}
Expand Down
67 changes: 54 additions & 13 deletions src/NuGetGallery.Core/CredentialTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,31 @@ public static class ApiKey

public const string ExternalPrefix = "external.";

public static bool IsPassword(string type)
public static bool IsPassword(this Credential c)
{
if (type == null)
{
throw new ArgumentNullException(nameof(type));
}
return IsPassword(c.Type);
}

return type.StartsWith(Password.Prefix, StringComparison.OrdinalIgnoreCase);
public static bool IsExternal(this Credential c)
{
return c.IsSubType(ExternalPrefix);
}

public static bool IsApiKey(string type)
public static bool IsApiKey(this Credential c)
{
return type.StartsWith(ApiKey.Prefix, StringComparison.OrdinalIgnoreCase);
return c.IsSubType(ApiKey.Prefix);
}

public static bool IsPackageVerificationApiKey(string type)
public static bool IsType(this Credential c, string type)
{
return type.Equals(ApiKey.VerifyV1, StringComparison.OrdinalIgnoreCase);
return IsType(c.Type, type);
}


private static bool IsSubType(this Credential c, string typePrefix)
{
return IsSubType(c.Type, typePrefix);
}

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

/// <summary>
Expand All @@ -69,13 +74,49 @@ public static bool IsSupportedCredential(this Credential credential)
/// <returns></returns>
public static bool IsViewSupportedCredential(this Credential credential)
{
return SupportedCredentialTypes.Any(credType => string.Compare(credential.Type, credType, StringComparison.OrdinalIgnoreCase) == 0)
|| credential.Type.StartsWith(ExternalPrefix, StringComparison.OrdinalIgnoreCase);
return
SupportedCredentialTypes.Any(credType => credential.IsType(credType)) ||
credential.IsExternal();
}

public static bool IsScopedApiKey(this Credential credential)
{
return IsApiKey(credential.Type) && credential.Scopes != null && credential.Scopes.Any();
}

public static bool IsPassword(string type)
{
return IsSubType(type, Password.Prefix);
}

public static bool IsApiKey(string type)
{
return IsSubType(type, ApiKey.Prefix);
}

public static bool IsPackageVerificationApiKey(string type)
{
return IsSubType(type, ApiKey.VerifyV1);
}

private static bool IsType(string actualType, string expectedType)
{
if (actualType == null)
{
throw new ArgumentNullException(nameof(actualType));
}

return actualType.Equals(expectedType, StringComparison.OrdinalIgnoreCase);
}

private static bool IsSubType(string actualType, string expectedTypePrefix)
Copy link
Contributor

@skofman1 skofman1 Jan 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@scottbommarito , I understand you wanted to make the methods use shared code, but in this case, since it's only a single line (credential.Type.StartsWith()) the result is overly complex. For example: IsPassword(Credential), instead of having a single line, now calls into IsPassword(type) => IsSubType() => string.StartsWith()
It's hard to read and not necessary.
Please undo this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially agreed with you and was going to make this change, but then I realized that despite the fact that it does create deeper call stacks, it also substantially reduces the code because there is no longer the need for every method to have an if (type == null) throw new ArgumentNullException in addition to the fact that it unifies all of these StartsWith and Equals calls.

@shishirx34's PR, that came after this one, also touches this code and creates about 20 lines for something that could be closer to 10 with this restructuring. (https://github.com/NuGet/NuGetGallery/pull/5355/files#diff-624219da9e2d31ff3446f6c5be30fe20R43)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the point shouldn't always be to reduce the number of lines if it makes the code unreadable. If few lines to a non-often changing code are repetitive its fine if they are duplicated. I agree with @skofman1 and feel like this is an unnecessary refactoring leading to hard to read code, please undo these changes and keep the code easy to read.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to be one-liners that return false if credential or type is null

{
if (actualType == null)
{
throw new ArgumentNullException(nameof(actualType));
}

return actualType.StartsWith(expectedTypePrefix, StringComparison.OrdinalIgnoreCase);
}
}
}
6 changes: 3 additions & 3 deletions src/NuGetGallery/Authentication/AuthenticationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ await Auditing.SaveAuditRecordAsync(

var passwordCredentials = user
.Credentials
.Where(c => CredentialTypes.IsPassword(c.Type))
.Where(c => c.IsPassword())
.ToList();

if (passwordCredentials.Count > 1 ||
Expand Down Expand Up @@ -160,7 +160,7 @@ public virtual async Task<AuthenticatedUser> Authenticate(Credential credential)

private async Task<AuthenticatedUser> AuthenticateInternal(Func<Credential, Credential> matchCredential, Credential credential)
{
if (credential.Type.StartsWith(CredentialTypes.Password.Prefix, StringComparison.OrdinalIgnoreCase))
if (credential.IsPassword())
{
// Password credentials cannot be used this way.
throw new ArgumentException(Strings.PasswordCredentialsCannotBeUsedHere, nameof(credential));
Expand Down Expand Up @@ -201,7 +201,7 @@ await Auditing.SaveAuditRecordAsync(
return null;
}

if (CredentialTypes.IsApiKey(matched.Type) &&
if (matched.IsApiKey() &&
!matched.IsScopedApiKey() &&
!matched.HasBeenUsedInLastDays(_config.ExpirationInDaysForApiKeyV1))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,16 @@ public override ActionResult Challenge(string redirectUrl)

public override IdentityInformation GetIdentityInformation(ClaimsIdentity claimsIdentity)
{
return ClaimsExtentions.GetIdentityInformation(claimsIdentity, DefaultAuthenticationType);
var identityInfo = ClaimsExtentions.GetIdentityInformation(claimsIdentity, DefaultAuthenticationType);

// The claims returned by AzureActiveDirectory have the email as the name claim are missing the email claim.
// Copy the object returned by the method but set the email as the name.
return new IdentityInformation(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the correct fix here would be to extract the name claim correctly from the claimsIdentity Otherwise the identity here would be email <email>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed it. All of the AAD on dev (and probably also int and prod as well) have incorrect identities as well.

identityInfo.Identifier,
identityInfo.Name,
identityInfo.Name,
identityInfo.AuthenticationType,
identityInfo.TenantId);
}
}
}
55 changes: 43 additions & 12 deletions src/NuGetGallery/Controllers/AuthenticationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,22 @@ public virtual async Task<ActionResult> SignIn(LogOnViewModel model, string retu
modelErrorMessage = string.Format(CultureInfo.CurrentCulture, Strings.UserAccountLocked, timeRemaining);
}

ModelState.AddModelError("SignIn", modelErrorMessage);

return SignInOrExternalLinkView(model, linkingAccount);
return SignInFailure(model, linkingAccount, modelErrorMessage);
}

var user = authenticationResult.AuthenticatedUser;
var authenticatedUser = authenticationResult.AuthenticatedUser;

if (linkingAccount)
{
// Verify account has no other external accounts
if (authenticatedUser.User.Credentials.Any(c => c.IsExternal()) && !authenticatedUser.User.IsAdministrator())
{
return SignInFailure(model, linkingAccount, Strings.LinkingMultipleExternalAccountsUnsupported);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this check here? Isn't this failure covered in LinkExternalAccount below?

Why 2 different error messages for these checks?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for the form POST, not the initial callback. There needs to be verification here in case somebody hacks the form to put the wrong value in.

The messages are basically the same, but adapted for the context in which they appear.


// Link with an external account
user = await AssociateCredential(user);
if (user == null)
authenticatedUser = await AssociateCredential(authenticatedUser);
if (authenticatedUser == null)
{
return ExternalLinkExpired();
}
Expand All @@ -165,16 +169,23 @@ public virtual async Task<ActionResult> SignIn(LogOnViewModel model, string retu
// to require a specific authentication provider, challenge that provider if needed.
ActionResult challenge;
if (ShouldChallengeEnforcedProvider(
NuGetContext.Config.Current.EnforcedAuthProviderForAdmin, user, returnUrl, out challenge))
NuGetContext.Config.Current.EnforcedAuthProviderForAdmin, authenticatedUser, returnUrl, out challenge))
{
return challenge;
}

// Create session
await _authService.CreateSessionAsync(OwinContext, user);
await _authService.CreateSessionAsync(OwinContext, authenticatedUser);
return SafeRedirect(returnUrl);
}

private ActionResult SignInFailure(LogOnViewModel model, bool linkingAccount, string modelErrorMessage)
{
ModelState.AddModelError("SignIn", modelErrorMessage);

return SignInOrExternalLinkView(model, linkingAccount);
}

internal bool ShouldChallengeEnforcedProvider(string enforcedProviders, AuthenticatedUser authenticatedUser, string returnUrl, out ActionResult challenge)
{
if (!string.IsNullOrEmpty(enforcedProviders)
Expand Down Expand Up @@ -362,9 +373,7 @@ public virtual async Task<ActionResult> LinkExternalAccount(string returnUrl)
{
// Consume the exception for now, for backwards compatibility to previous MSA provider.
email = result.ExternalIdentity.GetClaimOrDefault(ClaimTypes.Email);
name = result
.ExternalIdentity
.GetClaimOrDefault(ClaimTypes.Name);
name = result.ExternalIdentity.GetClaimOrDefault(ClaimTypes.Name);
}

// Check for a user with this email address
Expand All @@ -374,11 +383,33 @@ public virtual async Task<ActionResult> LinkExternalAccount(string returnUrl)
existingUser = _userService.FindByEmailAddress(email);
}

var foundExistingUser = existingUser != null;
string existingUserLinkingError = null;

if (foundExistingUser)
{
if (existingUser is Organization)
{
existingUserLinkingError = string.Format(
CultureInfo.CurrentCulture,
Strings.LinkingOrganizationUnsupported,
email);
}
else if (existingUser.Credentials.Any(c => c.IsExternal()) && !existingUser.IsAdministrator())
{
existingUserLinkingError = string.Format(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would put the string.Format inline. This doesn't improve code, and inline would be more maintainable if the resource string args ever diverge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

CultureInfo.CurrentCulture,
Strings.AccountIsLinkedToAnotherExternalAccount,
email);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would remove this block and just do string.Format when setting existingUserLinkingError.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we implement more error messages with different format strings we can do that, but right now I don't see a reason to do so.


var external = new AssociateExternalAccountViewModel()
{
ProviderAccountNoun = authUI.AccountNoun,
AccountName = name,
FoundExistingUser = existingUser != null
FoundExistingUser = foundExistingUser,
ExistingUserLinkingError = existingUserLinkingError
};

var model = new LogOnViewModel
Expand Down
2 changes: 1 addition & 1 deletion src/NuGetGallery/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,7 @@ private async Task<ActionResult> RemoveCredentialInternal(User user, Credential
}

// Count credentials and make sure the user can always login
if (!CredentialTypes.IsApiKey(cred.Type) && CountLoginCredentials(user) <= 1)
if (!cred.IsApiKey() && CountLoginCredentials(user) <= 1)
{
TempData["Message"] = Strings.CannotRemoveOnlyLoginCredential;
}
Expand Down
1 change: 0 additions & 1 deletion src/NuGetGallery/Extensions/UserExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// 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;
using System.Linq;
using System.Security.Claims;
Expand Down
2 changes: 1 addition & 1 deletion src/NuGetGallery/Security/SecurePushSubscription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public SecurePushSubscription(IAuditingService auditing, IDiagnosticsService dia
public async Task OnSubscribeAsync(UserSecurityPolicySubscriptionContext context)
{
var pushKeys = context.User.Credentials.Where(c =>
CredentialTypes.IsApiKey(c.Type) &&
c.IsApiKey() &&
(
c.Scopes.Count == 0 ||
c.Scopes.Any(s =>
Expand Down
27 changes: 27 additions & 0 deletions src/NuGetGallery/Strings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/NuGetGallery/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -736,4 +736,13 @@ For more information, please contact '{2}'.</value>
<data name="TransformAccount_RequestExists" xml:space="preserve">
<value>Another tranform request was created on {0} with organization admin '{1}'.</value>
</data>
<data name="AccountIsLinkedToAnotherExternalAccount" xml:space="preserve">
<value>We found an account with the email address associated with this external account ({0}), but linked to a different external account.</value>
</data>
<data name="LinkingMultipleExternalAccountsUnsupported" xml:space="preserve">
<value>You cannot link your NuGet.org account to multiple external accounts.</value>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this direct user to Account page to unlink other external accounts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anangaur was working on the messaging--not sure the status of it. I suggested doing something similar to him or at least a message explaining how to do so.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please run through the flow for strings with @anangaur once before merging.

</data>
<data name="LinkingOrganizationUnsupported" xml:space="preserve">
<value>We found an organization with the email address associated with this external account ({0}), but organizations cannot be linked to external accounts.</value>
</data>
</root>
2 changes: 2 additions & 0 deletions src/NuGetGallery/ViewModels/LogOnViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public class AssociateExternalAccountViewModel
public string ProviderAccountNoun { get; set; }
public string AccountName { get; set; }
public bool FoundExistingUser { get; set; }
public bool ExistingUserCanBeLinked => string.IsNullOrEmpty(ExistingUserLinkingError);
public string ExistingUserLinkingError { get; set; }
}

public class SignInViewModel
Expand Down
Loading