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

[2FA] Add AAD login support for NuGet Gallery #5257

Merged
merged 20 commits into from
Jan 11, 2018
Merged

[2FA] Add AAD login support for NuGet Gallery #5257

merged 20 commits into from
Jan 11, 2018

Conversation

shishirx34
Copy link
Contributor

This PR adds AAD support to NuGet gallery. The new login will work just like the personal MSA. You can login with your AAD account and link it to an existing nuget.org account or create a new account. This is configured as a new provider, so that we can transition away from previous way of logging in.
I will add new configuration values in deployment PR. This PR uses Microsoft.Owin.Security.OpenIdConnect middleware which can now login with a common endpoint that facilitates single sign on flow for Personal/Work microsoft accounts.

Also, the code takes care of maintaining backwards compatibility with existing MSA accounts and translates the account information into older format. Also added tests for the new provider and some missing coverage in AuthenticationService.

{
foreach (var authenticator in Authenticators.Values)
var userInfo = author.GetAuthInformation(externalIdentity);
var emailSuffix = userInfo.Email == null ? String.Empty : (" <" + userInfo.Email + ">");
Copy link
Member

Choose a reason for hiding this comment

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

Very much a nit and pretty sure the gallery code base is inconsistent, but should we prefer string over String?

Copy link
Contributor Author

@shishirx34 shishirx34 Jan 10, 2018

Choose a reason for hiding this comment

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

Yes, I just copied this over from previous place. I usually use string. Will fix it.

  • done

public AuthInformation() { }

public AuthInformation(string identifier, string name, string email, string authenticationType, string tenantId = null)
{
Copy link
Member

Choose a reason for hiding this comment

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

Do we need argument null checks here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope, validation can be done in the service where needed.


public string AuthenticationType { get; private set; }

public AuthInformation() { }
Copy link
Member

@xavierdecoster xavierdecoster Jan 10, 2018

Choose a reason for hiding this comment

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

Empty constructor and only read-only property access? Smells weird..

  • removed

/// <returns><see cref="AuthInformation"/></returns>
public virtual AuthInformation GetAuthInformation(ClaimsIdentity claimsIdentity)
{
return new AuthInformation();
Copy link
Member

Choose a reason for hiding this comment

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

Is there any use in returning this new AuthInformation()? Should this become an abstract method instead?

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 agree, its better.

@@ -474,12 +473,21 @@ public virtual ActionResult Challenge(string providerName, string redirectUrl)
public virtual CredentialViewModel DescribeCredential(Credential credential)
{
var kind = GetCredentialKind(credential.Type);
Authenticator auther = null;
Authenticator author = null;
Copy link
Member

@chenriksson chenriksson Jan 10, 2018

Choose a reason for hiding this comment

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

I think auther was intentional... as in, the one doing the "auth".

Agree it's a bad name, but maybe authenticator is a better one?

  • done

/// </summary>
/// <param name="claimsIdentity">The claims identity returned by the identity</param>
/// <returns>Returns true if this provider is the author for the identity, false otherwise</returns>
public virtual bool IsAuthorForIdentity(ClaimsIdentity claimsIdentity)
Copy link
Member

@chenriksson chenriksson Jan 10, 2018

Choose a reason for hiding this comment

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

Author and Auther are confusing me.

Can we rename auther to authenticator, and maybe change this signature to bool IsProviderForIdentity(ClaimsIdentity)?

  • done

{
if (string.Equals(issuer, BaseConfig.AuthenticationType, StringComparison.OrdinalIgnoreCase))
// If the issuer of the claims identity is same as that of the authentication type then this is the author.
Claim firstClaim = claimsIdentity?.Claims?.FirstOrDefault();
Copy link
Member

@chenriksson chenriksson Jan 10, 2018

Choose a reason for hiding this comment

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

nit: use var

  • done

}

authenticationType = null;
return false;
return string.Equals(firstClaim.Issuer, BaseConfig.AuthenticationType, StringComparison.OrdinalIgnoreCase);
Copy link
Member

Choose a reason for hiding this comment

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

If BaseConfig.AuthenticationType were guaranteed to be non-null, we could remove the block above and just do:

string.Equals(firstClaim?.Issuer, BaseConfig.AuthenticationType, StringComparison.OrdinalIgnoreCase);

I don't think AuthenticationConfiguration enforces this though. :-(

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are right, it is not enforced. I would rather have the check to prevent unseen issues later.

}

return base.TryMapIssuerToAuthenticationType(issuer, out authenticationType);
return MSAIdentityExtractor.GetAuthInformation(claimsIdentity, DefaultAuthenticationType);
Copy link
Member

Choose a reason for hiding this comment

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

MSAIdentityExtractor is for both MSA and AAD?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, for the previous schema MSA and AAD used the same claims to get values. This is an interim thing until we get rid of old providers.

}

public static readonly string DefaultAuthenticationType = "CommonAuth";
public static readonly string PersonalMSATenant = "9188040d-6c67-4c5b-b112-36a304b66dad";
Copy link
Member

Choose a reason for hiding this comment

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

Looks like this is personal (non-tenant) token id? Does that imply MSA, or can still be AAD?

Just looking at:
https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-oauth-code

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 the tenant for Personal MSA accounts, under the v2 protocol it is returned as the tenant id for Personal MSA accounts, same as the previous external.MicrosoftAccount credentials validated using the MSA flow.

if (siteRoot.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
siteRoot = siteRoot.Replace("http://", "https://");
}
Copy link
Member

Choose a reason for hiding this comment

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

Is this necessary - don't we already have redirect configuration for this?

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 value is passed to the Azure AD to redirect with the claims after authentication. This is added as a query string and validated on the AD side before redirection, it needs to be https

Copy link
Member

Choose a reason for hiding this comment

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

Maybe clarify in comment why this redirect is necessary for AAD, so nobody mistakenly removes it?

// where as the existing Microsoft account identifiers are 16 character wide.
// For e.g old format: 0ae45d63e22e4a60, newer format: 00000000-0000-0000-0AE4-5D63-E22E4A60
// We need to format the values into the older format for backwards compatibility
identifier = idClaim.Value.Replace("-", "").Substring(16).ToLowerInvariant();
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 know why they left pad with zeros? Just wondering if we'll ever need those digits... would it be useful to have 2 versions of the Id claim, for 16- and 32- characters?

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 checked with Identity folks and it is safe to use the 16 character string for MSA accounts.

var emailClaim = claimsIdentity.FindFirst(ClaimTypes.Email);
return new AuthInformation(identifierClaim.Value, nameClaim.Value, emailClaim?.Value, authType, tenantId: null);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Is there anything MSA specific in here? Could this just be put into a generic ClaimsExtensions class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nothing specific for MSA, renamed it to ClaimsExtensions, this will be removed eventually when we remove older providers.

name = userInfo.Name;
email = userInfo.Email;
}
catch (Exception)
Copy link
Member

Choose a reason for hiding this comment

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

Is there a more specific Exception type we expect to see? What's the scenario?

Should we add trace log?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Before this change, when we found a user trying to register a new account directly with MSA, we would show email we got from the auth and allow the user to edit. Going forward, we will not allow this to happen, however, for backwards compatibility(more so especially for tests) we get one or the other claim. I haven't yet modified those tests as we need to maintain backwards compatibility. Hence, for now we will swallow the exceptions. In future we will surface the error to the view model and show appropriate error to the user. No point in adding trace here yet.


namespace NuGetGallery.Authentication.Providers
{
public class AuthInformation
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use full words (AuthenticationInformation)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

renamed to IdentityInformation which seems more accurate.

{
author = authenticator;
break;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

you can retwrite this using FirstOrDefault

using NuGetGallery.Configuration;
using Owin;

namespace NuGetGallery.Authentication.Providers.CommonAuth
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use Aythentication and not Auth everywhere

var nameClaim = claimsIdentity.FindFirst(V2Claims.Name);
if (nameClaim == null)
{
throw new ArgumentException($"External Authentication is missing required claim: '{V2Claims.Name}'");
Copy link
Contributor

Choose a reason for hiding this comment

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

why do we require Name claim? Why would it be missing?

Copy link
Contributor Author

@shishirx34 shishirx34 Jan 10, 2018

Choose a reason for hiding this comment

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

removed the name claim null check, since it is not a necessity.

var identifierClaim = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier);
if (identifierClaim == null)
{
throw new ArgumentException("External Authentication is missing required claim: " + ClaimTypes.NameIdentifier);
Copy link
Contributor

@skofman1 skofman1 Jan 10, 2018

Choose a reason for hiding this comment

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

nit: the 2 exception strings are formatted differently

  • fixed.

Authenticators.TryGetValue(providerName, out auther);
if (string.IsNullOrEmpty(credential.TenantId))
{
string providerName = credential.Type.Split('.')[1];
Copy link
Contributor

Choose a reason for hiding this comment

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

Will the providerName always be the second ?

Copy link
Contributor Author

@shishirx34 shishirx34 Jan 10, 2018

Choose a reason for hiding this comment

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

Yes. external.MicrosoftAccount external.AzureActiveDiretory

return string.Equals(issuer.Value, expectedIssuer, StringComparison.OrdinalIgnoreCase);
}

public override AuthInformation GetAuthInformation(ClaimsIdentity claimsIdentity)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

May be combine this and above method as TryGet

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 looked into this, and I think its better to throw the exception here, given that we want to disallow missing emails going forward. In subsequent PRs I will clean it up to throw a well defined exception that we can surface to the user as missing email.


if (kind == CredentialKind.External)
{
string providerName = credential.Type.Split('.')[1];
Authenticators.TryGetValue(providerName, out auther);
if (string.IsNullOrEmpty(credential.TenantId))
Copy link
Member

Choose a reason for hiding this comment

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

So, we'll switch over to the new APIs (OpenIdConnect) once the SQL is run to populate tenantId for existing credentials?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, and after we enable the config values for new provider.

Copy link
Member

@chenriksson chenriksson left a comment

Choose a reason for hiding this comment

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

Looks great!

{
authenticator = Authenticators
.Values
.First(provider => provider.Name.Equals(AzureActiveDirectoryV2Authenticator.DefaultAuthenticationType, StringComparison.OrdinalIgnoreCase));
Copy link
Member

@chenriksson chenriksson Jan 11, 2018

Choose a reason for hiding this comment

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

Why not use TryGetValue here or FirstOrDefault like you do for identity lookup. Just wondering about consistency.

  • Good idea to use FirstOrDefault

var nameClaim = result.Identity.FindFirst(ClaimTypes.Name);
if (nameClaim == null)
var externalIdentity = result.Identity;
Authenticator author = Authenticators
Copy link
Member

Choose a reason for hiding this comment

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

Would rename author => authenticator

@@ -687,13 +682,13 @@ private string FormatCredentialType(string credentialType)

private string FormatExternalCredentialType(string externalType)
{
Authenticator auther;
if (!Authenticators.TryGetValue(externalType, out auther))
Authenticator author;
Copy link
Member

Choose a reason for hiding this comment

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

Would rename author => authenticator

/// </summary>
/// <param name="claimsIdentity">The claims identity returned by the identity</param>
/// <returns><see cref="IdentityInformation"/></returns>
public virtual IdentityInformation GetIdentityInformation(ClaimsIdentity claimsIdentity)
Copy link
Member

Choose a reason for hiding this comment

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

Why not leave this abstract? Do any children actually use this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, there are other children where we need default value.

if (siteRoot.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
siteRoot = siteRoot.Replace("http://", "https://");
}
Copy link
Member

Choose a reason for hiding this comment

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

Maybe clarify in comment why this redirect is necessary for AAD, so nobody mistakenly removes it?

{
base.ApplyToOwinSecurityOptions(options);

var opts = options as OpenIdConnectAuthenticationOptions;
Copy link
Member

Choose a reason for hiding this comment

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

nit: would rename to reflect more specific cast, like openIdOptions

[Fact]
public void GetUIReturnsCorrectValues()
{
//Arrange
Copy link
Member

Choose a reason for hiding this comment

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

nit: space after //

@shishirx34 shishirx34 merged commit d29d2f1 into dev Jan 11, 2018
@shishirx34 shishirx34 deleted the 2fa-phase0-aad branch January 11, 2018 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants