Skip to content

Commit b2cb381

Browse files
author
Daniel Jacinto
authored
[2FA] Enforce 2FA for new accounts. (#9035)
* Remove disable-enable 2FA button. * Enable 2fa by default for new accounts. * Link or replacing new accounts without 2FA enabled show error. * Enable 2FA button * fix current tests. * Removed unecessary 2fa html. * nit * Added new tests for authentication controller. * feature flag. * fix enable/disable button logic. * nit tests. * fix Register enabling 2fa. * improve UseAccountChangeExternalCredential and fix tests. * nit. * Removed non needed nullable references. * Added test for enable on controller register method. * Update EnableMultiFactorAuthentication on creation.
1 parent fbab6da commit b2cb381

15 files changed

Lines changed: 371 additions & 43 deletions

File tree

src/AccountDeleter/EmptyFeatureFlagService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ public bool IsMarkdigMdRenderingEnabled()
141141
throw new NotImplementedException();
142142
}
143143

144+
public bool IsNewAccount2FAEnforcementEnabled()
145+
{
146+
throw new NotImplementedException();
147+
}
148+
144149
public bool IsODataDatabaseReadOnlyEnabled()
145150
{
146151
throw new NotImplementedException();

src/GitHubVulnerabilities2Db/Fakes/FakeFeatureFlagService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,5 +274,10 @@ public bool IsRecentPackagesNoIndexEnabled()
274274
{
275275
throw new NotImplementedException();
276276
}
277+
278+
public bool IsNewAccount2FAEnforcementEnabled()
279+
{
280+
throw new NotImplementedException();
281+
}
277282
}
278283
}

src/NuGetGallery.Services/Authentication/AuthenticationService.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ private async Task<Claim[]> GetUserLoginClaims(AuthenticatedUser user, bool wasM
345345
.Where(cred => cred.IsExternal())
346346
.Select(cred => cred.Identity)
347347
.ToArray();
348-
348+
349349
var identityList = string.Join(" or ", externalIdentities);
350350
ClaimsExtensions.AddExternalCredentialIdentityClaim(claims, identityList);
351351
}
@@ -365,7 +365,7 @@ private async Task<Claim[]> GetUserLoginClaims(AuthenticatedUser user, bool wasM
365365
return claims.ToArray();
366366
}
367367

368-
public virtual async Task<AuthenticatedUser> Register(string username, string emailAddress, Credential credential, bool autoConfirm = false)
368+
public virtual async Task<AuthenticatedUser> Register(string username, string emailAddress, Credential credential, bool autoConfirm = false, bool enableMultiFactorAuthentication = false)
369369
{
370370
if (_config.FeedOnlyMode)
371371
{
@@ -392,7 +392,8 @@ public virtual async Task<AuthenticatedUser> Register(string username, string em
392392
UnconfirmedEmailAddress = emailAddress,
393393
EmailConfirmationToken = CryptographyService.GenerateToken(),
394394
NotifyPackagePushed = true,
395-
CreatedUtc = _dateTimeProvider.UtcNow
395+
CreatedUtc = _dateTimeProvider.UtcNow,
396+
EnableMultiFactorAuthentication = enableMultiFactorAuthentication
396397
};
397398

398399
// Add a credential for the password
@@ -799,15 +800,15 @@ private async Task ReplaceCredentialInternal(User user, Credential credential)
799800
Func<Credential, bool> replacingPredicate;
800801
if (!string.IsNullOrEmpty(replaceCredPrefix))
801802
{
802-
replacingPredicate = cred => cred.Type.StartsWith(replaceCredPrefix, StringComparison.OrdinalIgnoreCase);
803+
replacingPredicate = cred => cred.Type.StartsWith(replaceCredPrefix, StringComparison.OrdinalIgnoreCase);
803804
}
804805
else
805806
{
806807
replacingPredicate = cred => cred.Type.Equals(credential.Type, StringComparison.OrdinalIgnoreCase);
807808
}
808809

809810
var toRemove = user.Credentials
810-
.Where(replacingPredicate)
811+
.Where(replacingPredicate)
811812
.ToList();
812813

813814
foreach (var cred in toRemove)

src/NuGetGallery.Services/Configuration/FeatureFlagService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public class FeatureFlagService : IFeatureFlagService
5353
private const string DisplayTargetFrameworkFeatureName = GalleryPrefix + "DisplayTargetFramework";
5454
private const string ComputeTargetFrameworkFeatureName = GalleryPrefix + "ComputeTargetFramework";
5555
private const string RecentPackagesNoIndexFeatureName = GalleryPrefix + "RecentPackagesNoIndex";
56+
private const string NewAccount2FAEnforcementFeatureName = GalleryPrefix + "NewAccount2FAEnforcement";
5657

5758
private const string ODataV1GetAllNonHijackedFeatureName = GalleryPrefix + "ODataV1GetAllNonHijacked";
5859
private const string ODataV1GetAllCountNonHijackedFeatureName = GalleryPrefix + "ODataV1GetAllCountNonHijacked";
@@ -362,5 +363,10 @@ public bool IsRecentPackagesNoIndexEnabled()
362363
{
363364
return _client.IsEnabled(RecentPackagesNoIndexFeatureName, defaultValue: false);
364365
}
366+
367+
public bool IsNewAccount2FAEnforcementEnabled()
368+
{
369+
return _client.IsEnabled(NewAccount2FAEnforcementFeatureName, defaultValue: false);
370+
}
365371
}
366372
}

src/NuGetGallery.Services/Configuration/IFeatureFlagService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,5 +291,10 @@ public interface IFeatureFlagService
291291
/// Whether or not recent packages has no index applied to block search engine indexing.
292292
/// </summary>
293293
bool IsRecentPackagesNoIndexEnabled();
294+
295+
/// <summary>
296+
/// Whether or not to enforce 2FA for new external account link or replacement.
297+
/// </summary>
298+
bool IsNewAccount2FAEnforcementEnabled();
294299
}
295300
}

src/NuGetGallery/App_Data/Files/Content/flags.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"NuGetGallery.ImageAllowlist": "Enabled",
3333
"NuGetGallery.ShowReportAbuseSafetyChanges": "Enabled",
3434
"NuGetGallery.ComputeTargetFramework": "Enabled",
35+
"NuGetGallery.NewAccount2FAEnforcement": "Disabled"
3536
},
3637
"Flights": {
3738
"NuGetGallery.TyposquattingFlight": {

src/NuGetGallery/Controllers/AccountsController.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ public class ViewMessages
5656
protected IIconUrlProvider IconUrlProvider { get; }
5757

5858
protected IGravatarProxyService GravatarProxy { get; }
59+
60+
protected IFeatureFlagService FeatureFlagService { get; }
5961

6062
private readonly DeleteAccountListPackageItemViewModelFactory _deleteAccountListPackageItemViewModelFactory;
6163

@@ -71,7 +73,8 @@ public AccountsController(
7173
IMessageServiceConfiguration messageServiceConfiguration,
7274
IDeleteAccountService deleteAccountService,
7375
IIconUrlProvider iconUrlProvider,
74-
IGravatarProxyService gravatarProxy)
76+
IGravatarProxyService gravatarProxy,
77+
IFeatureFlagService featureFlagService)
7578
{
7679
AuthenticationService = authenticationService ?? throw new ArgumentNullException(nameof(authenticationService));
7780
PackageService = packageService ?? throw new ArgumentNullException(nameof(packageService));
@@ -85,6 +88,7 @@ public AccountsController(
8588
DeleteAccountService = deleteAccountService ?? throw new ArgumentNullException(nameof(deleteAccountService));
8689
IconUrlProvider = iconUrlProvider ?? throw new ArgumentNullException(nameof(iconUrlProvider));
8790
GravatarProxy = gravatarProxy ?? throw new ArgumentNullException(nameof(gravatarProxy));
91+
FeatureFlagService = featureFlagService ?? throw new ArgumentNullException(nameof(featureFlagService));
8892

8993
_deleteAccountListPackageItemViewModelFactory = new DeleteAccountListPackageItemViewModelFactory(PackageService, IconUrlProvider);
9094
}
@@ -444,6 +448,7 @@ protected virtual void UpdateAccountViewModel(TUser account, TAccountViewModel m
444448

445449
model.IsCertificatesUIEnabled = ContentObjectService.CertificatesConfiguration?.IsUIEnabledForUser(currentUser) ?? false;
446450
model.WasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated();
451+
model.IsNewAccount2FAEnforcementEnabled = FeatureFlagService.IsNewAccount2FAEnforcementEnabled();
447452

448453
model.HasPassword = account.Credentials.Any(c => c.IsPassword());
449454
model.CurrentEmailAddress = account.UnconfirmedEmailAddress ?? account.EmailAddress;

src/NuGetGallery/Controllers/AuthenticationController.cs

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,12 @@ public partial class AuthenticationController
3434
: AppController
3535
{
3636
private readonly AuthenticationService _authService;
37-
3837
private readonly IUserService _userService;
39-
4038
private readonly IMessageService _messageService;
41-
4239
private readonly ICredentialBuilder _credentialBuilder;
43-
4440
private readonly IContentObjectService _contentObjectService;
4541
private readonly IMessageServiceConfiguration _messageServiceConfiguration;
42+
private readonly IFeatureFlagService _featureFlagService;
4643
private const string EMAIL_FORMAT_PADDING = "**********";
4744

4845
// Prioritize the external authentication mechanism.
@@ -57,14 +54,16 @@ public AuthenticationController(
5754
IMessageService messageService,
5855
ICredentialBuilder credentialBuilder,
5956
IContentObjectService contentObjectService,
60-
IMessageServiceConfiguration messageServiceConfiguration)
57+
IMessageServiceConfiguration messageServiceConfiguration,
58+
IFeatureFlagService featureFlagService)
6159
{
6260
_authService = authService ?? throw new ArgumentNullException(nameof(authService));
6361
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
6462
_messageService = messageService ?? throw new ArgumentNullException(nameof(messageService));
6563
_credentialBuilder = credentialBuilder ?? throw new ArgumentNullException(nameof(credentialBuilder));
6664
_contentObjectService = contentObjectService ?? throw new ArgumentNullException(nameof(contentObjectService));
6765
_messageServiceConfiguration = messageServiceConfiguration ?? throw new ArgumentNullException(nameof(messageServiceConfiguration));
66+
_featureFlagService = featureFlagService ?? throw new ArgumentNullException(nameof(featureFlagService));
6867
}
6968

7069
/// <summary>
@@ -295,12 +294,13 @@ public virtual async Task<ActionResult> Register(LogOnViewModel model, string re
295294
}
296295

297296
usedMultiFactorAuthentication = result.LoginDetails?.WasMultiFactorAuthenticated ?? false;
297+
var enableMultiFactorAuthentication = _featureFlagService.IsNewAccount2FAEnforcementEnabled() ? true : usedMultiFactorAuthentication;
298298
user = await _authService.Register(
299299
model.Register.Username,
300300
model.Register.EmailAddress,
301301
result.Credential,
302-
(result.Credential.IsExternal() && string.Equals(result.UserInfo?.Email, model.Register.EmailAddress))
303-
);
302+
autoConfirm: (result.Credential.IsExternal() && string.Equals(result.UserInfo?.Email, model.Register.EmailAddress)),
303+
enableMultiFactorAuthentication: enableMultiFactorAuthentication);
304304
}
305305
else
306306
{
@@ -507,6 +507,15 @@ public virtual async Task<ActionResult> LinkOrChangeExternalCredential(string re
507507
return SafeRedirect(returnUrl);
508508
}
509509

510+
// All new linking or replacing accounts should have 2FA enabled.
511+
if (_featureFlagService.IsNewAccount2FAEnforcementEnabled() && !result.UserInfo.UsedMultiFactorAuthentication)
512+
{
513+
return ChallengeAuthentication(
514+
Url.LinkOrChangeExternalCredential(returnUrl),
515+
result.Authenticator.Name,
516+
new AuthenticationPolicy() { Email = result.LoginDetails.EmailUsed, EnforceMultiFactorAuthentication = true });
517+
}
518+
510519
var newCredential = result.Credential;
511520
if (await _authService.TryReplaceCredential(user, newCredential))
512521
{
@@ -518,6 +527,16 @@ public virtual async Task<ActionResult> LinkOrChangeExternalCredential(string re
518527
var usedMultiFactorAuthentication = result.LoginDetails?.WasMultiFactorAuthenticated ?? false;
519528
await _authService.CreateSessionAsync(OwinContext, authenticatedUser, usedMultiFactorAuthentication);
520529

530+
// Update the 2FA if used during login but user does not have it set on their account.
531+
if (result?.LoginDetails != null
532+
&& usedMultiFactorAuthentication
533+
&& !user.EnableMultiFactorAuthentication
534+
&& CredentialTypes.IsExternal(result.Credential))
535+
{
536+
await _userService.ChangeMultiFactorAuthentication(user, enableMultiFactor: true, referrer: "Authentication");
537+
OwinContext.AddClaim(NuGetClaims.EnabledMultiFactorAuthentication);
538+
}
539+
521540
// Get email address of the new credential for updating success message
522541
var newEmailAddress = GetEmailAddressFromExternalLoginResult(result, out string errorReason);
523542
if (!string.IsNullOrEmpty(errorReason))
@@ -627,6 +646,14 @@ await _authService.CreateSessionAsync(OwinContext,
627646

628647
return SafeRedirect(returnUrl);
629648
}
649+
else if (_featureFlagService.IsNewAccount2FAEnforcementEnabled() && CredentialTypes.IsExternal(result.Credential) && !result.LoginDetails.WasMultiFactorAuthenticated)
650+
{
651+
// Invoke the authentication again enforcing multi-factor authentication for the same provider.
652+
return ChallengeAuthentication(
653+
Url.LinkExternalAccount(returnUrl),
654+
result.Authenticator.Name,
655+
new AuthenticationPolicy() { Email = result.LoginDetails.EmailUsed, EnforceMultiFactorAuthentication = true });
656+
}
630657
else
631658
{
632659
// Gather data for view model
@@ -703,13 +730,15 @@ internal bool ShouldEnforceMultiFactorAuthentication(AuthenticateExternalLoginRe
703730
// Enforce multi-factor authentication only if:
704731
// 1. The authenticator supports multi-factor authentication, otherwise no use.
705732
// 2. The user has enabled multi-factor authentication for their account.
706-
// 3. The user authenticated with the personal microsoft account. AAD 2FA policy is controlled by the tenant admins.
707-
// 4. The user did not use the multi-factor authentication for the session, obviously.
733+
// 3. The user did not use the multi-factor authentication for the session, obviously.
734+
// 4. The user authenticated with an external account (currently only MSA and AAD are supported).
735+
// 5. If the 2FA enforcement for new accounts is enabled all external account types should be enforced (step 4 validated this).
736+
// If not, only user authenticated with a personal microsoft account is enforced. AAD 2FA policy is controlled by the tenant admins.
708737
return result.Authenticator.SupportsMultiFactorAuthentication()
709738
&& result.Authentication.User.EnableMultiFactorAuthentication
710739
&& !result.LoginDetails.WasMultiFactorAuthenticated
711740
&& result.Authentication.CredentialUsed.IsExternal()
712-
&& (CredentialTypes.IsMicrosoftAccount(result.Authentication.CredentialUsed.Type));
741+
&& (_featureFlagService.IsNewAccount2FAEnforcementEnabled() || CredentialTypes.IsMicrosoftAccount(result.Authentication.CredentialUsed.Type));
713742
}
714743

715744
private string FormatEmailAddressForAssistance(string email)

src/NuGetGallery/Controllers/OrganizationsController.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ public OrganizationsController(
4848
messageServiceConfiguration,
4949
deleteAccountService,
5050
iconUrlProvider,
51-
gravatarProxy)
51+
gravatarProxy,
52+
features)
5253
{
5354
_features = features ?? throw new ArgumentNullException(nameof(features));
5455
}

src/NuGetGallery/Controllers/UsersController.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ public UsersController(
6969
messageServiceConfiguration,
7070
deleteAccountService,
7171
iconUrlProvider,
72-
gravatarProxy)
72+
gravatarProxy,
73+
featureFlagService)
7374
{
7475
_packageOwnerRequestService = packageOwnerRequestService ?? throw new ArgumentNullException(nameof(packageOwnerRequestService));
7576
_config = config ?? throw new ArgumentNullException(nameof(config));

0 commit comments

Comments
 (0)