diff --git a/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs b/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs index d12a2edbde..3ae5429c66 100644 --- a/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs +++ b/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs @@ -21,6 +21,8 @@ public enum AuditedUserAction TransformOrganization, AddOrganizationMember, RemoveOrganizationMember, - UpdateOrganizationMember + UpdateOrganizationMember, + EnabledMultiFactorAuthentication, + DisabledMultiFactorAuthentication } } \ No newline at end of file diff --git a/src/NuGetGallery.Core/Services/CoreMessageService.cs b/src/NuGetGallery.Core/Services/CoreMessageService.cs index cd0245ed6d..9d90ba4a15 100644 --- a/src/NuGetGallery.Core/Services/CoreMessageService.cs +++ b/src/NuGetGallery.Core/Services/CoreMessageService.cs @@ -141,7 +141,7 @@ public void SendValidationTakingTooLongNotice(Package package, string packageUrl mailMessage.Body = body; mailMessage.From = CoreConfiguration.GalleryNoReplyAddress; - AddAllOwnersToMailMessage(package.PackageRegistration, mailMessage); + AddOwnersSubscribedToPackagePushedNotification(package.PackageRegistration, mailMessage); if (mailMessage.To.Any()) { diff --git a/src/NuGetGallery/App_Data/Files/Content/Certificates-Configuration.json b/src/NuGetGallery/App_Data/Files/Content/Certificates-Configuration.json new file mode 100644 index 0000000000..9d9067d5ca --- /dev/null +++ b/src/NuGetGallery/App_Data/Files/Content/Certificates-Configuration.json @@ -0,0 +1,7 @@ +{ + "isUIEnabledByDefault": false, + "alwaysEnabledForDomains": [ + ], + "alwaysEnabledForEmailAddresses": [ + ] +} \ No newline at end of file diff --git a/src/NuGetGallery/Constants.cs b/src/NuGetGallery/Constants.cs index fef12e3dfe..595605bc91 100644 --- a/src/NuGetGallery/Constants.cs +++ b/src/NuGetGallery/Constants.cs @@ -98,6 +98,7 @@ public static class ContentNames public static readonly string PrivacyPolicy = "Privacy-Policy"; public static readonly string Team = "Team"; public static readonly string LoginDiscontinuationConfiguration = "Login-Discontinuation-Configuration"; + public static readonly string CertificatesConfiguration = "Certificates-Configuration"; } public static class StatisticsDimensions diff --git a/src/NuGetGallery/Controllers/AccountsController.cs b/src/NuGetGallery/Controllers/AccountsController.cs index 5a97e3fc53..956c73b06c 100644 --- a/src/NuGetGallery/Controllers/AccountsController.cs +++ b/src/NuGetGallery/Controllers/AccountsController.cs @@ -43,6 +43,8 @@ public class ViewMessages public ICertificateService CertificateService { get; } + public IContentObjectService ContentObjectService { get; } + public AccountsController( AuthenticationService authenticationService, ICuratedFeedService curatedFeedService, @@ -51,7 +53,8 @@ public AccountsController( IUserService userService, ITelemetryService telemetryService, ISecurityPolicyService securityPolicyService, - ICertificateService certificateService) + ICertificateService certificateService, + IContentObjectService contentObjectService) { AuthenticationService = authenticationService ?? throw new ArgumentNullException(nameof(authenticationService)); CuratedFeedService = curatedFeedService ?? throw new ArgumentNullException(nameof(curatedFeedService)); @@ -61,6 +64,7 @@ public AccountsController( TelemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); SecurityPolicyService = securityPolicyService ?? throw new ArgumentNullException(nameof(securityPolicyService)); CertificateService = certificateService ?? throw new ArgumentNullException(nameof(certificateService)); + ContentObjectService = contentObjectService ?? throw new ArgumentNullException(nameof(contentObjectService)); } public abstract string AccountAction { get; } @@ -332,9 +336,12 @@ protected virtual void UpdateAccountViewModel(TUser account, TAccountViewModel m model.Account = account; model.AccountName = account.Username; + var currentUser = GetCurrentUser(); + model.CanManage = ActionsRequiringPermissions.ManageAccount.CheckPermissions( - GetCurrentUser(), account) == PermissionsCheckResult.Allowed; + currentUser, account) == PermissionsCheckResult.Allowed; + model.IsCertificatesUIEnabled = ContentObjectService.CertificatesConfiguration?.IsUIEnabledForUser(currentUser) ?? false; model.WasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated(); model.CuratedFeeds = CuratedFeedService diff --git a/src/NuGetGallery/Controllers/OrganizationsController.cs b/src/NuGetGallery/Controllers/OrganizationsController.cs index b73e464859..d007a4ea2b 100644 --- a/src/NuGetGallery/Controllers/OrganizationsController.cs +++ b/src/NuGetGallery/Controllers/OrganizationsController.cs @@ -28,7 +28,8 @@ public OrganizationsController( ISecurityPolicyService securityPolicyService, ICertificateService certificateService, IPackageService packageService, - IDeleteAccountService deleteAccountService) + IDeleteAccountService deleteAccountService, + IContentObjectService contentObjectService) : base( authService, curatedFeedService, @@ -37,7 +38,8 @@ public OrganizationsController( userService, telemetryService, securityPolicyService, - certificateService) + certificateService, + contentObjectService) { DeleteAccountService = deleteAccountService; } diff --git a/src/NuGetGallery/Controllers/PackagesController.cs b/src/NuGetGallery/Controllers/PackagesController.cs index f78ae0b22c..68f982e3a1 100644 --- a/src/NuGetGallery/Controllers/PackagesController.cs +++ b/src/NuGetGallery/Controllers/PackagesController.cs @@ -84,6 +84,7 @@ public partial class PackagesController private readonly IReadMeService _readMeService; private readonly IValidationService _validationService; private readonly IPackageOwnershipManagementService _packageOwnershipManagementService; + private readonly IContentObjectService _contentObjectService; public PackagesController( IPackageService packageService, @@ -106,7 +107,8 @@ public PackagesController( IPackageUploadService packageUploadService, IReadMeService readMeService, IValidationService validationService, - IPackageOwnershipManagementService packageOwnershipManagementService) + IPackageOwnershipManagementService packageOwnershipManagementService, + IContentObjectService contentObjectService) { _packageService = packageService; _uploadFileService = uploadFileService; @@ -129,6 +131,7 @@ public PackagesController( _readMeService = readMeService; _validationService = validationService; _packageOwnershipManagementService = packageOwnershipManagementService; + _contentObjectService = contentObjectService; } [HttpGet] @@ -463,6 +466,7 @@ public virtual async Task DisplayPackage(string id, string version model.ValidatingTooLong = _validationService.IsValidatingTooLong(package); model.ValidationIssues = _validationService.GetLatestValidationIssues(package); + model.IsCertificatesUIEnabled = _contentObjectService.CertificatesConfiguration?.IsUIEnabledForUser(currentUser) ?? false; model.ReadMeHtml = await _readMeService.GetReadMeHtmlAsync(package); diff --git a/src/NuGetGallery/Controllers/UsersController.cs b/src/NuGetGallery/Controllers/UsersController.cs index 3d3d055184..747e40e2c9 100644 --- a/src/NuGetGallery/Controllers/UsersController.cs +++ b/src/NuGetGallery/Controllers/UsersController.cs @@ -41,7 +41,8 @@ public UsersController( ISupportRequestService supportRequestService, ITelemetryService telemetryService, ISecurityPolicyService securityPolicyService, - ICertificateService certificateService) + ICertificateService certificateService, + IContentObjectService contentObjectService) : base( authService, feedsQuery, @@ -50,7 +51,8 @@ public UsersController( userService, telemetryService, securityPolicyService, - certificateService) + certificateService, + contentObjectService) { _packageOwnerRequestService = packageOwnerRequestService ?? throw new ArgumentNullException(nameof(packageOwnerRequestService)); _config = config ?? throw new ArgumentNullException(nameof(config)); @@ -479,7 +481,8 @@ public virtual ActionResult Packages() UnlistedPackages = unlistedPackages, OwnerRequests = ownerRequests, ReservedNamespaces = reservedPrefixes, - WasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated() + WasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated(), + IsCertificatesUIEnabled = ContentObjectService.CertificatesConfiguration?.IsUIEnabledForUser(currentUser) ?? false }; return View(model); } diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj index 76030fa30e..88f182d985 100644 --- a/src/NuGetGallery/NuGetGallery.csproj +++ b/src/NuGetGallery/NuGetGallery.csproj @@ -862,12 +862,14 @@ + + @@ -2018,6 +2020,7 @@ + diff --git a/src/NuGetGallery/Services/CertificatesConfiguration.cs b/src/NuGetGallery/Services/CertificatesConfiguration.cs new file mode 100644 index 0000000000..647f108218 --- /dev/null +++ b/src/NuGetGallery/Services/CertificatesConfiguration.cs @@ -0,0 +1,59 @@ +// 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.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace NuGetGallery.Services +{ + public sealed class CertificatesConfiguration : ICertificatesConfiguration + { + public bool IsUIEnabledByDefault { get; } + public HashSet AlwaysEnabledForEmailAddresses { get; } + public HashSet AlwaysEnabledForDomains { get; } + + public CertificatesConfiguration() + : this(isUIEnabledByDefault: false, + alwaysEnabledForDomains: Enumerable.Empty(), + alwaysEnabledForEmailAddresses: Enumerable.Empty()) + { + } + + [JsonConstructor] + public CertificatesConfiguration( + bool isUIEnabledByDefault, + IEnumerable alwaysEnabledForDomains, + IEnumerable alwaysEnabledForEmailAddresses) + { + if (alwaysEnabledForDomains == null) + { + throw new ArgumentNullException(nameof(alwaysEnabledForDomains)); + } + + if (alwaysEnabledForEmailAddresses == null) + { + throw new ArgumentNullException(nameof(alwaysEnabledForEmailAddresses)); + } + + IsUIEnabledByDefault = isUIEnabledByDefault; + AlwaysEnabledForDomains = new HashSet(alwaysEnabledForDomains, StringComparer.OrdinalIgnoreCase); + AlwaysEnabledForEmailAddresses = new HashSet(alwaysEnabledForEmailAddresses, StringComparer.OrdinalIgnoreCase); + } + + public bool IsUIEnabledForUser(User user) + { + if (user == null) + { + return false; + } + + var email = user.ToMailAddress(); + + return IsUIEnabledByDefault || + AlwaysEnabledForDomains.Contains(email.Host) || + AlwaysEnabledForEmailAddresses.Contains(email.Address); + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/ContentObjectService.cs b/src/NuGetGallery/Services/ContentObjectService.cs index 5e60bf29d6..8c8e581d10 100644 --- a/src/NuGetGallery/Services/ContentObjectService.cs +++ b/src/NuGetGallery/Services/ContentObjectService.cs @@ -2,9 +2,9 @@ // 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.Threading.Tasks; using Newtonsoft.Json; +using NuGetGallery.Services; namespace NuGetGallery { @@ -19,15 +19,21 @@ public ContentObjectService(IContentService contentService) _contentService = contentService; LoginDiscontinuationConfiguration = new LoginDiscontinuationConfiguration(); + CertificatesConfiguration = new CertificatesConfiguration(); } public ILoginDiscontinuationConfiguration LoginDiscontinuationConfiguration { get; set; } + public ICertificatesConfiguration CertificatesConfiguration { get; set; } public async Task Refresh() { LoginDiscontinuationConfiguration = await Refresh(Constants.ContentNames.LoginDiscontinuationConfiguration) ?? new LoginDiscontinuationConfiguration(); + + CertificatesConfiguration = + await Refresh(Constants.ContentNames.CertificatesConfiguration) ?? + new CertificatesConfiguration(); } private async Task Refresh(string contentName) diff --git a/src/NuGetGallery/Services/ICertificatesConfiguration.cs b/src/NuGetGallery/Services/ICertificatesConfiguration.cs new file mode 100644 index 0000000000..d9667fce21 --- /dev/null +++ b/src/NuGetGallery/Services/ICertificatesConfiguration.cs @@ -0,0 +1,10 @@ +// 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. + +namespace NuGetGallery.Services +{ + public interface ICertificatesConfiguration + { + bool IsUIEnabledForUser(User user); + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/IContentObjectService.cs b/src/NuGetGallery/Services/IContentObjectService.cs index 9f907eee96..1a0eb8e2fe 100644 --- a/src/NuGetGallery/Services/IContentObjectService.cs +++ b/src/NuGetGallery/Services/IContentObjectService.cs @@ -2,13 +2,15 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Threading.Tasks; +using NuGetGallery.Services; namespace NuGetGallery { public interface IContentObjectService { ILoginDiscontinuationConfiguration LoginDiscontinuationConfiguration { get; } + ICertificatesConfiguration CertificatesConfiguration { get; } Task Refresh(); } -} +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/IMessageService.cs b/src/NuGetGallery/Services/IMessageService.cs index 159dbc0114..b971bb9c4f 100644 --- a/src/NuGetGallery/Services/IMessageService.cs +++ b/src/NuGetGallery/Services/IMessageService.cs @@ -26,7 +26,6 @@ public interface IMessageService void SendCredentialAddedNotice(User user, CredentialViewModel addedCredentialViewModel); void SendContactSupportEmail(ContactSupportRequest request); void SendPackageAddedNotice(Package package, string packageUrl, string packageSupportUrl, string emailSettingsUrl); - void SendPackageUploadedNotice(Package package, string packageUrl, string packageSupportUrl, string emailSettingsUrl); void SendAccountDeleteNotice(User user); void SendPackageDeletedNotice(Package package, string packageUrl, string packageSupportUrl); void SendSigninAssistanceEmail(MailAddress emailAddress, IEnumerable credentials); diff --git a/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs b/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs index 9c465441b0..231183e8ed 100644 --- a/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs +++ b/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs @@ -12,11 +12,11 @@ namespace NuGetGallery { public class LoginDiscontinuationConfiguration : ILoginDiscontinuationConfiguration { - internal HashSet DiscontinuedForEmailAddresses { get; } - internal HashSet DiscontinuedForDomains { get; } - internal HashSet ExceptionsForEmailAddresses { get; } - internal HashSet ForceTransformationToOrganizationForEmailAddresses { get; } - internal HashSet EnabledOrganizationAadTenants { get; } + public HashSet DiscontinuedForEmailAddresses { get; } + public HashSet DiscontinuedForDomains { get; } + public HashSet ExceptionsForEmailAddresses { get; } + public HashSet ForceTransformationToOrganizationForEmailAddresses { get; } + public HashSet EnabledOrganizationAadTenants { get; } public LoginDiscontinuationConfiguration() : this(Enumerable.Empty(), diff --git a/src/NuGetGallery/Services/MessageService.cs b/src/NuGetGallery/Services/MessageService.cs index 75619bafdd..f3d773f5f8 100644 --- a/src/NuGetGallery/Services/MessageService.cs +++ b/src/NuGetGallery/Services/MessageService.cs @@ -636,34 +636,6 @@ private void SendSupportMessage(User user, string body, string subject) } } - public void SendPackageUploadedNotice(Package package, string packageUrl, string packageSupportUrl, string emailSettingsUrl) - { - string subject = $"[{Config.GalleryOwner.DisplayName}] Package uploaded - {package.PackageRegistration.Id} {package.Version}"; - string body = $@"The package [{package.PackageRegistration.Id} {package.Version}]({packageUrl}) was recently uploaded to {Config.GalleryOwner.DisplayName} by {package.User.Username}. If this was not intended, please [contact support]({packageSupportUrl}). - -Note: This package has not been published yet. It will appear in search results and will be available for install/restore after both validation and indexing are complete. Package validation and indexing may take up to an hour. - ------------------------------------------------ - - To stop receiving emails as an owner of this package, sign in to the {Config.GalleryOwner.DisplayName} and - [change your email notification settings]({emailSettingsUrl}). -"; - - using (var mailMessage = new MailMessage()) - { - mailMessage.Subject = subject; - mailMessage.Body = body; - mailMessage.From = Config.GalleryNoReplyAddress; - - AddOwnersSubscribedToPackagePushedNotification(package.PackageRegistration, mailMessage); - - if (mailMessage.To.Any()) - { - SendMessage(mailMessage); - } - } - } - public void SendAccountDeleteNotice(User user) { string body = @"We received a request to delete your account {0}. If you did not initiate this request, please contact the {1} team immediately. diff --git a/src/NuGetGallery/Services/UserService.cs b/src/NuGetGallery/Services/UserService.cs index 9c159ae184..5e8f9d02d7 100644 --- a/src/NuGetGallery/Services/UserService.cs +++ b/src/NuGetGallery/Services/UserService.cs @@ -400,6 +400,8 @@ public async Task CancelChangeEmailAddress(User user) public virtual async Task ChangeMultiFactorAuthentication(User user, bool enableMultiFactor) { + await Auditing.SaveAuditRecordAsync(new UserAuditRecord(user, enableMultiFactor ? AuditedUserAction.EnabledMultiFactorAuthentication : AuditedUserAction.DisabledMultiFactorAuthentication)); + user.EnableMultiFactorAuthentication = enableMultiFactor; await UserRepository.CommitChangesAsync(); diff --git a/src/NuGetGallery/ViewModels/AccountViewModel.cs b/src/NuGetGallery/ViewModels/AccountViewModel.cs index ab46258116..525cb98758 100644 --- a/src/NuGetGallery/ViewModels/AccountViewModel.cs +++ b/src/NuGetGallery/ViewModels/AccountViewModel.cs @@ -9,6 +9,8 @@ public abstract class AccountViewModel : AccountViewModel where T : User { public T Account { get; set; } + public bool IsCertificatesUIEnabled { get; set; } + public override User User => Account; } diff --git a/src/NuGetGallery/ViewModels/DisplayPackageViewModel.cs b/src/NuGetGallery/ViewModels/DisplayPackageViewModel.cs index bdecf48869..3206b707ff 100644 --- a/src/NuGetGallery/ViewModels/DisplayPackageViewModel.cs +++ b/src/NuGetGallery/ViewModels/DisplayPackageViewModel.cs @@ -40,10 +40,10 @@ public DisplayPackageViewModel(Package package, User currentUser, string pushedB : base(package, currentUser) { Copyright = package.Copyright; - + DownloadCount = package.DownloadCount; LastEdited = package.LastEdited; - + TotalDaysSinceCreated = 0; DownloadsPerDay = 0; @@ -81,6 +81,8 @@ public bool HasNewerPrerelease public string PushedBy { get; private set; } + public bool IsCertificatesUIEnabled { get; set; } + private IDictionary _pushedByCache = new Dictionary(); private string GetPushedBy(Package package, User currentUser) diff --git a/src/NuGetGallery/ViewModels/ListPackageItemRequiredSignerViewModel.cs b/src/NuGetGallery/ViewModels/ListPackageItemRequiredSignerViewModel.cs index 80a8bac8ec..427d4de609 100644 --- a/src/NuGetGallery/ViewModels/ListPackageItemRequiredSignerViewModel.cs +++ b/src/NuGetGallery/ViewModels/ListPackageItemRequiredSignerViewModel.cs @@ -12,7 +12,8 @@ namespace NuGetGallery public sealed class ListPackageItemRequiredSignerViewModel : ListPackageItemViewModel { // username must be an empty string because option value. private static readonly SignerViewModel AnySigner = new SignerViewModel(username: "", displayText: "Any"); @@ -133,7 +134,14 @@ public ListPackageItemRequiredSignerViewModel( var ownersWithRequiredSignerControl = owners.Where( owner => securityPolicyService.IsSubscribed(owner, ControlRequiredSignerPolicy.PolicyName)); - RequiredSignerMessage = GetRequiredSignerMessage(ownersWithRequiredSignerControl); + if (owners.Count() == 1) + { + ShowTextBox = true; + } + else + { + RequiredSignerMessage = GetRequiredSignerMessage(ownersWithRequiredSignerControl); + } } CanEditRequiredSigner &= wasMultiFactorAuthenticated; diff --git a/src/NuGetGallery/ViewModels/ManagePackagesViewModel.cs b/src/NuGetGallery/ViewModels/ManagePackagesViewModel.cs index f4995ea4e2..3fe588a01f 100644 --- a/src/NuGetGallery/ViewModels/ManagePackagesViewModel.cs +++ b/src/NuGetGallery/ViewModels/ManagePackagesViewModel.cs @@ -20,5 +20,7 @@ public class ManagePackagesViewModel public ReservedNamespaceListViewModel ReservedNamespaces { get; set; } public bool WasMultiFactorAuthenticated { get; set; } + + public bool IsCertificatesUIEnabled { get; set; } } } \ No newline at end of file diff --git a/src/NuGetGallery/Views/Organizations/ManageOrganization.cshtml b/src/NuGetGallery/Views/Organizations/ManageOrganization.cshtml index 2da1705216..a089c91b38 100644 --- a/src/NuGetGallery/Views/Organizations/ManageOrganization.cshtml +++ b/src/NuGetGallery/Views/Organizations/ManageOrganization.cshtml @@ -73,7 +73,10 @@ @Html.Partial("_AccountCuratedFeeds", Model) - @Html.Partial("_AccountCertificates", Model) + @if (Model.IsCertificatesUIEnabled) + { + @Html.Partial("_AccountCertificates", Model) + } @if (Model.CanManage) { @@ -120,12 +123,14 @@ })); @ViewHelpers.SectionsScript(this); - @Scripts.Render("~/Scripts/gallery/certificates.js") @Scripts.Render("~/Scripts/gallery/page-manage-organization.min.js") - - + @if (Model.IsCertificatesUIEnabled) + { + @Scripts.Render("~/Scripts/gallery/certificates.js") + + } + @if (Model.IsCertificatesUIEnabled) + { + @Scripts.Render("~/Scripts/gallery/certificates.js") + + }