diff --git a/src/Bootstrap/dist/css/bootstrap-theme.css b/src/Bootstrap/dist/css/bootstrap-theme.css index ae926ac6d0..2e49bcaecc 100644 --- a/src/Bootstrap/dist/css/bootstrap-theme.css +++ b/src/Bootstrap/dist/css/bootstrap-theme.css @@ -87,21 +87,9 @@ body h3 { height: auto; padding-bottom: 75px; } -.main-container .page-subheading { - color: #777; -} .navbar-logo { margin: 8px 20px 0 0; } -.navbar-seperated .seperator { - display: block; - padding: 11px 0; - font-weight: 100; - color: #fff; -} -.navbar-seperated .seperator > span::after { - content: " | "; -} .dropdown-menu { padding-top: 20px; padding-bottom: 20px; @@ -613,6 +601,26 @@ img.reserved-indicator-icon { .page-account-settings .form-group + .form-group .btn { margin-top: 20px; } +.page-account-settings .login-account-row { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding-top: 10px; + + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} +.page-account-settings .certificates-form { + display: inline-block; + width: 100%; +} .page-api-keys .example-commands { padding: 8px 10px; font-family: Consolas, Menlo, Monaco, "Courier New", monospace; @@ -797,6 +805,15 @@ img.reserved-indicator-icon { overflow-wrap: break-word; } +.page-package-details .package-details-main .signature-info-cell { + max-width: 1em; + padding-right: 0; + padding-left: 0; + cursor: default; +} +.page-package-details .package-details-main .signature-info-cell .signature-info { + padding-left: 6px; +} .page-package-details .package-details-info .ms-Icon-ul li { margin-bottom: 15px; } @@ -1064,35 +1081,33 @@ img.reserved-indicator-icon { .page-manage-organizations .form-group + .form-group .btn { margin-top: 20px; } +.page-manage-organizations .certificates-form { + display: inline-block; + width: 100%; +} .manage-members-listing tbody:first-child { border-style: hidden; } -.members-list { - margin-top: 15px; -} .members-list .manage-members-listing { margin-bottom: 0; } -.members-list .manage-members-listing .heading-left { - padding-left: 5px; -} -.members-list .manage-members-listing .heading-right { - padding-right: 5px; -} -.members-list .manage-members-listing .icon-left { - padding-left: 25px; -} -.members-list .manage-members-listing .icon-right { - padding-right: 20px; +.members-list .manage-members-listing .alert-container .alert { + margin-top: 0; } .members-list .manage-members-listing .role-description { - margin-top: 15px; + margin-top: 25px; margin-bottom: 25px; } -.members-list .manage-members-listing .alert { - margin-bottom: 5px; +.members-list .manage-members-listing .member-item hr { + margin-top: 8px; + margin-bottom: 8px; } -.members-list .manage-members-listing .row-center { +.members-list .manage-members-listing .member-item .member-column { + display: table; + height: 36px; +} +.members-list .manage-members-listing .member-item .member-column div { + display: table-cell; vertical-align: middle; } .page-manage-owners h2 .ms-Icon { @@ -1135,6 +1150,9 @@ img.reserved-indicator-icon { .page-manage-owners .current-owner .remove-owner-disabled .icon-link:hover span { text-decoration: none; } +.page-manage-packages .organizations-divider { + color: #dbdbdb; +} .page-manage-packages h1 { margin-bottom: 0; } @@ -1142,6 +1160,9 @@ img.reserved-indicator-icon { margin-top: 0; margin-bottom: 10px; } +.page-manage-packages .organizations-empty { + margin-top: 60px; +} .page-manage-packages .subtitle { margin-top: 33px; } @@ -1169,6 +1190,9 @@ img.reserved-indicator-icon { position: relative; top: 2px; } +.page-manage-packages .inner-table { + margin-bottom: 0; +} .page-delete-package h1 { margin-bottom: 0; } @@ -1583,4 +1607,8 @@ img.reserved-indicator-icon { color: #ea7918; vertical-align: bottom; } +.checkmark-icon { + color: green; + vertical-align: bottom; +} /*# sourceMappingURL=bootstrap-theme.css.map */ diff --git a/src/Bootstrap/less/theme/base.less b/src/Bootstrap/less/theme/base.less index 934c451634..304e495fda 100644 --- a/src/Bootstrap/less/theme/base.less +++ b/src/Bootstrap/less/theme/base.less @@ -103,29 +103,12 @@ body { .main-container { padding-bottom: 75px; height: auto; - - .page-subheading { - color: #777; - } } .navbar-logo { margin: 8px 20px 0 0; } -.navbar-seperated { - .seperator { - padding: 11px 0; - color: #fff; - display: block; - font-weight: 100; - - > span::after { - content: " | "; - } - } -} - .dropdown-menu { padding-top: (@padding-large-vertical * 2); padding-bottom: (@padding-large-vertical * 2); diff --git a/src/Bootstrap/less/theme/page-account-settings.less b/src/Bootstrap/less/theme/page-account-settings.less index f4abe5a381..b0e62de003 100644 --- a/src/Bootstrap/less/theme/page-account-settings.less +++ b/src/Bootstrap/less/theme/page-account-settings.less @@ -37,4 +37,16 @@ margin-top: 20px; } } + + .login-account-row { + padding-top: 10px; + display: flex; + align-items: center; + justify-content: space-between; + } + + .certificates-form { + display: inline-block; + width: 100%; + } } diff --git a/src/Bootstrap/less/theme/page-display-package.less b/src/Bootstrap/less/theme/page-display-package.less index b037596f66..2579878f6e 100644 --- a/src/Bootstrap/less/theme/page-display-package.less +++ b/src/Bootstrap/less/theme/page-display-package.less @@ -21,6 +21,17 @@ .package-details-main { .break-word; + + .signature-info-cell { + cursor: default; + max-width: 1em; + padding-left: 0; + padding-right: 0; + + .signature-info { + padding-left: 6px; + } + } } .package-details-info { diff --git a/src/Bootstrap/less/theme/page-header.less b/src/Bootstrap/less/theme/page-header.less index c1bac8a888..28579d3df8 100644 --- a/src/Bootstrap/less/theme/page-header.less +++ b/src/Bootstrap/less/theme/page-header.less @@ -1,4 +1,9 @@ .warning-icon { vertical-align: bottom; color: #ea7918; +} + +.checkmark-icon { + vertical-align: bottom; + color: green; } \ No newline at end of file diff --git a/src/Bootstrap/less/theme/page-manage-organizations.less b/src/Bootstrap/less/theme/page-manage-organizations.less index 236947fac9..608145d9e3 100644 --- a/src/Bootstrap/less/theme/page-manage-organizations.less +++ b/src/Bootstrap/less/theme/page-manage-organizations.less @@ -37,6 +37,11 @@ margin-top: 20px; } } + + .certificates-form { + display: inline-block; + width: 100%; + } } .manage-members-listing tbody:first-child { @@ -44,38 +49,35 @@ } .members-list { - margin-top: 15px; - .manage-members-listing { margin-bottom: 0; - - .heading-left { - padding-left: 5px; - } - - .heading-right { - padding-right: 5px; - } - - .icon-left { - padding-left: 25px; - } - - .icon-right { - padding-right: 20px; + + .alert-container { + .alert { + margin-top: 0px; + } } - + .role-description { - margin-top: 15px; + margin-top: 25px; margin-bottom: 25px; } - .alert { - margin-bottom: 5px; - } + .member-item { + hr { + margin-top: 8px; + margin-bottom: 8px; + } - .row-center { - vertical-align: middle; + .member-column { + display: table; + height: 36px; + + div { + display: table-cell; + vertical-align: middle; + } + } } } -} \ No newline at end of file +} diff --git a/src/Bootstrap/less/theme/page-manage-packages.less b/src/Bootstrap/less/theme/page-manage-packages.less index 40ca7eef7f..b3e200baac 100644 --- a/src/Bootstrap/less/theme/page-manage-packages.less +++ b/src/Bootstrap/less/theme/page-manage-packages.less @@ -1,6 +1,10 @@ .page-manage-packages { @section-margin-top: 40px; + .organizations-divider { + color: @gray-lighter; + } + h1 { margin-bottom: 0; } @@ -10,6 +14,10 @@ margin-top: 0; } + .organizations-empty { + margin-top: 60px; + } + .subtitle { margin-top: 33px; } @@ -42,4 +50,8 @@ top: 2px; } } + + .inner-table { + margin-bottom: 0px; + } } diff --git a/src/NuGetGallery.Core/Auditing/AuditedCertificateAction.cs b/src/NuGetGallery.Core/Auditing/AuditedCertificateAction.cs new file mode 100644 index 0000000000..16522dbd54 --- /dev/null +++ b/src/NuGetGallery.Core/Auditing/AuditedCertificateAction.cs @@ -0,0 +1,12 @@ +// 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.Auditing +{ + public enum AuditedCertificateAction + { + Add, + Activate, + Deactivate + } +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/Auditing/AuditedPackageRegistrationAction.cs b/src/NuGetGallery.Core/Auditing/AuditedPackageRegistrationAction.cs index 18f3ae25cb..0ce132da8c 100644 --- a/src/NuGetGallery.Core/Auditing/AuditedPackageRegistrationAction.cs +++ b/src/NuGetGallery.Core/Auditing/AuditedPackageRegistrationAction.cs @@ -8,6 +8,7 @@ public enum AuditedPackageRegistrationAction AddOwner, RemoveOwner, MarkVerified, - MarkUnverified + MarkUnverified, + SetRequiredSigner } } \ No newline at end of file diff --git a/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs b/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs index 048f1d5686..d12a2edbde 100644 --- a/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs +++ b/src/NuGetGallery.Core/Auditing/AuditedUserAction.cs @@ -16,6 +16,11 @@ public enum AuditedUserAction ConfirmEmail, Login, SubscribeToPolicies, - UnsubscribeFromPolicies + UnsubscribeFromPolicies, + AddOrganization, + TransformOrganization, + AddOrganizationMember, + RemoveOrganizationMember, + UpdateOrganizationMember } } \ No newline at end of file diff --git a/src/NuGetGallery.Core/Auditing/CertificateAuditRecord.cs b/src/NuGetGallery.Core/Auditing/CertificateAuditRecord.cs new file mode 100644 index 0000000000..5e70f3ec3f --- /dev/null +++ b/src/NuGetGallery.Core/Auditing/CertificateAuditRecord.cs @@ -0,0 +1,34 @@ +// 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.Auditing +{ + public sealed class CertificateAuditRecord : AuditRecord + { + public string Thumbprint { get; } + public string HashAlgorithm => "SHA-256"; + + public CertificateAuditRecord(AuditedCertificateAction action, string thumbprint) + : base(action) + { + if (string.IsNullOrEmpty(thumbprint)) + { + throw new ArgumentException(CoreStrings.ArgumentCannotBeNullOrEmpty, nameof(thumbprint)); + } + + if (thumbprint.Length != 64) // Did the thumbprint hash algorithm change? + { + throw new ArgumentException(CoreStrings.CertificateThumbprintHashAlgorithmChanged, nameof(thumbprint)); + } + + Thumbprint = thumbprint.ToLowerInvariant(); + } + + public override string GetPath() + { + return Thumbprint; + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/Auditing/PackageRegistrationAuditRecord.cs b/src/NuGetGallery.Core/Auditing/PackageRegistrationAuditRecord.cs index 7109234abe..40a3eb2821 100644 --- a/src/NuGetGallery.Core/Auditing/PackageRegistrationAuditRecord.cs +++ b/src/NuGetGallery.Core/Auditing/PackageRegistrationAuditRecord.cs @@ -1,6 +1,7 @@ // 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 NuGetGallery.Auditing.AuditedEntities; namespace NuGetGallery.Auditing @@ -10,6 +11,8 @@ public class PackageRegistrationAuditRecord : AuditRecord public string UnconfirmedEmailAddress { get; } public string[] Roles { get; } public CredentialAuditRecord[] Credentials { get; } + + /// + /// The credential affected by . + /// public CredentialAuditRecord[] AffectedCredential { get; } + + /// + /// The email address affected by . + /// public string AffectedEmailAddress { get; } /// - /// Subset of user policies affected by the action (subscription / unsubscription). + /// The username of the member affected by . /// - public AuditedUserSecurityPolicy[] AffectedPolicies { get; } + public string AffectedMemberUsername { get; } - public UserAuditRecord(User user, AuditedUserAction action) - : this(user, action, Enumerable.Empty()) - { - } + /// + /// Whether or not the member affected by is an administrator or not. + /// + public bool AffectedMemberIsAdmin { get; } - public UserAuditRecord(User user, AuditedUserAction action, Credential affected) - : this(user, action, SingleEnumerable(affected)) - { - } + /// + /// Subset of user policies affected by . + /// + public AuditedUserSecurityPolicy[] AffectedPolicies { get; } - public UserAuditRecord(User user, AuditedUserAction action, IEnumerable affected) + public UserAuditRecord(User user, AuditedUserAction action) : base(action) { if (user == null) @@ -45,25 +53,45 @@ public UserAuditRecord(User user, AuditedUserAction action, IEnumerable r.Name).ToArray(); + Credentials = user.Credentials.Where(CredentialTypes.IsSupportedCredential) .Select(c => new CredentialAuditRecord(c, removed: false)).ToArray(); - if (affected != null) - { - AffectedCredential = affected.Select(c => new CredentialAuditRecord(c, action == AuditedUserAction.RemoveCredential)).ToArray(); - } + AffectedCredential = new CredentialAuditRecord[0]; + AffectedPolicies = new AuditedUserSecurityPolicy[0]; + } + + public UserAuditRecord(User user, AuditedUserAction action, Credential affected) + : this(user, action, new[] { affected }) + { + } - Action = action; + public UserAuditRecord(User user, AuditedUserAction action, IEnumerable affected) + : this(user, action) + { + AffectedCredential = affected.Select(c => new CredentialAuditRecord(c, action == AuditedUserAction.RemoveCredential)).ToArray(); } - + public UserAuditRecord(User user, AuditedUserAction action, string affectedEmailAddress) - : this(user, action, Enumerable.Empty()) + : this(user, action) { AffectedEmailAddress = affectedEmailAddress; } + public UserAuditRecord(User user, AuditedUserAction action, User affectedMember, bool affectedMemberIsAdmin) + : this(user, action) + { + AffectedMemberUsername = affectedMember.Username; + AffectedMemberIsAdmin = affectedMemberIsAdmin; + } + + public UserAuditRecord(User user, AuditedUserAction action, Membership affectedMembership) + : this(user, action, affectedMembership.Member, affectedMembership.IsAdmin) + { + } + public UserAuditRecord(User user, AuditedUserAction action, IEnumerable affectedPolicies) - : this(user, action, Enumerable.Empty()) + : this(user, action) { if (affectedPolicies == null || affectedPolicies.Count() == 0) { @@ -77,10 +105,5 @@ public override string GetPath() { return Username.ToLowerInvariant(); } - - private static IEnumerable SingleEnumerable(Credential affected) - { - yield return affected; - } } } diff --git a/src/NuGetGallery.Core/Authentication/NuGetClaims.cs b/src/NuGetGallery.Core/Authentication/NuGetClaims.cs index a365457ecb..c081677fca 100644 --- a/src/NuGetGallery.Core/Authentication/NuGetClaims.cs +++ b/src/NuGetGallery.Core/Authentication/NuGetClaims.cs @@ -36,5 +36,30 @@ public static class NuGetClaims /// The claim url for the claim that stores whether or not the user has an external login. /// public const string ExternalLogin = ClaimsDomain + "externallogin"; + + /// + /// The claim url for the claim that stores whether or not the user has enabled multi-factor authentication. + /// + public const string EnabledMultiFactorAuthentication = ClaimsDomain + "enabledmultifactorauthentication"; + + /// + /// The claim url for the claim that stores whether or not the user was multi-factor authenticated for the current session. + /// + public const string WasMultiFactorAuthenticated = ClaimsDomain + "wasmultifactorauthenticated"; + + /// + /// The claim url for the claim that stores the type of credential used for authentication for the current session. + /// + public const string ExternalLoginCredentialType = ClaimsDomain + "externallogincredentialtype"; + + /// + /// The class for all possible values for claim. + /// + public class ExternalLoginCredentialValues + { + public const string MicrosoftAccount = "msa"; + + public const string AzureActiveDirectory = "aad"; + } } } diff --git a/src/NuGetGallery.Core/Certificates/CertificateFile.cs b/src/NuGetGallery.Core/Certificates/CertificateFile.cs new file mode 100644 index 0000000000..c8627e7da4 --- /dev/null +++ b/src/NuGetGallery.Core/Certificates/CertificateFile.cs @@ -0,0 +1,96 @@ +// 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.IO; +using System.Security.Cryptography; + +namespace NuGetGallery +{ + public sealed class CertificateFile : IDisposable + { + private static readonly byte[] EmptyBuffer = new byte[0]; + + public string Sha1Thumbprint { get; } + public string Sha256Thumbprint { get; } + public Stream Stream { get; } + + private CertificateFile(Stream stream, string sha1Thumbprint, string sha256Thumbprint) + { + Stream = stream; + Sha1Thumbprint = sha1Thumbprint; + Sha256Thumbprint = sha256Thumbprint; + } + + public void Dispose() + { + Stream.Dispose(); + } + + public static CertificateFile Create(Stream stream) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + var readOnlyStream = CopyAsReadOnly(stream); + + var sha1Thumbprint = GetSha1Thumbprint(readOnlyStream); + var thumbprint = GetSha256Thumbprint(readOnlyStream); + + readOnlyStream.Position = 0; + + return new CertificateFile(readOnlyStream, sha1Thumbprint, thumbprint); + } + + private static MemoryStream CopyAsReadOnly(Stream source) + { + var capacity = (int)source.Length; + + using (var destination = new MemoryStream(capacity: capacity)) + { + source.Position = 0; + source.CopyTo(destination); + + return new MemoryStream( + destination.GetBuffer(), + index: 0, + count: capacity, + writable: false, + publiclyVisible: true); + } + } + + private static string GetSha1Thumbprint(MemoryStream stream) + { + using (var hashAlgorithm = SHA1.Create()) + { + return GetThumbprint(stream, hashAlgorithm); + } + } + + private static string GetSha256Thumbprint(MemoryStream stream) + { + using (var hashAlgorithm = SHA256.Create()) + { + return GetThumbprint(stream, hashAlgorithm); + } + } + + private static string GetThumbprint(MemoryStream stream, HashAlgorithm hashAlgorithm) + { + var buffer = stream.GetBuffer(); + + hashAlgorithm.TransformBlock(buffer, 0, buffer.Length, outputBuffer: null, outputOffset: 0); + hashAlgorithm.TransformFinalBlock(EmptyBuffer, inputOffset: 0, inputCount: 0); + + return GetHexString(hashAlgorithm.Hash); + } + + private static string GetHexString(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/CoreConstants.cs b/src/NuGetGallery.Core/CoreConstants.cs index c0ea412777..ccc6003fb3 100644 --- a/src/NuGetGallery.Core/CoreConstants.cs +++ b/src/NuGetGallery.Core/CoreConstants.cs @@ -13,6 +13,7 @@ public static class CoreConstants public const string PackageFileBackupSavePathTemplate = "{0}/{1}/{2}.{3}"; public const string NuGetPackageFileExtension = ".nupkg"; + public const string CertificateFileExtension = ".cer"; public const string Sha512HashAlgorithmId = "SHA512"; @@ -20,11 +21,12 @@ public static class CoreConstants public const string OctetStreamContentType = "application/octet-stream"; public const string TextContentType = "text/plain"; + public const string UserCertificatesFolderName = "user-certificates"; public const string ContentFolderName = "content"; public const string DownloadsFolderName = "downloads"; public const string PackageBackupsFolderName = "package-backups"; public const string PackageReadMesFolderName = "readmes"; - public const string PackagesFolderName = "packages"; + public const string PackagesFolderName = "packages"; public const string UploadsFolderName = "uploads"; public const string ValidationFolderName = "validation"; } diff --git a/src/NuGetGallery.Core/CoreStrings.Designer.cs b/src/NuGetGallery.Core/CoreStrings.Designer.cs index b60a3e63ea..d2d4bcd9a8 100644 --- a/src/NuGetGallery.Core/CoreStrings.Designer.cs +++ b/src/NuGetGallery.Core/CoreStrings.Designer.cs @@ -60,6 +60,33 @@ internal CoreStrings() { } } + /// + /// Looks up a localized string similar to The argument cannot be null or empty.. + /// + public static string ArgumentCannotBeNullOrEmpty { + get { + return ResourceManager.GetString("ArgumentCannotBeNullOrEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The certificate does not exist.. + /// + public static string CertificateNotFound { + get { + return ResourceManager.GetString("CertificateNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The thumbprint is expected to be a SHA-256 thumbprint, which is exactly 64 characters in length. Did the hash algorithm change?. + /// + public static string CertificateThumbprintHashAlgorithmChanged { + get { + return ResourceManager.GetString("CertificateThumbprintHashAlgorithmChanged", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unable to write audit record: '{0}'. Record already exists.. /// @@ -276,6 +303,15 @@ public static string PackageMetadata_VersionStringInvalid { } } + /// + /// Looks up a localized string similar to The package does not exist.. + /// + public static string PackageNotFound { + get { + return ResourceManager.GetString("PackageNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Must be a readable stream. /// diff --git a/src/NuGetGallery.Core/CoreStrings.resx b/src/NuGetGallery.Core/CoreStrings.resx index 195f5227a4..06d1ab38d8 100644 --- a/src/NuGetGallery.Core/CoreStrings.resx +++ b/src/NuGetGallery.Core/CoreStrings.resx @@ -201,4 +201,16 @@ The package manifest contains invalid metadata elements: '{0}' {0} is a comma-delimited list of invalid metadata element names. + + The argument cannot be null or empty. + + + The package does not exist. + + + The certificate does not exist. + + + The thumbprint is expected to be a SHA-256 thumbprint, which is exactly 64 characters in length. Did the hash algorithm change? + \ No newline at end of file diff --git a/src/NuGetGallery.Core/Entities/Certificate.cs b/src/NuGetGallery.Core/Entities/Certificate.cs index a6d30d543b..882ff5a982 100644 --- a/src/NuGetGallery.Core/Entities/Certificate.cs +++ b/src/NuGetGallery.Core/Entities/Certificate.cs @@ -1,8 +1,12 @@ // 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.Collections.Generic; +using System.ComponentModel.DataAnnotations; + namespace NuGetGallery { + [DisplayColumn(nameof(Thumbprint))] public class Certificate : IEntity { /// @@ -10,11 +14,26 @@ public class Certificate : IEntity /// public int Key { get; set; } + /// + /// Gets or sets the SHA-1 thumbprint of the certificate. + /// + public string Sha1Thumbprint { get; set; } + /// /// The SHA-256 thumbprint (fingerprint) that uniquely identifies this certificate. This is a string with /// exactly 64 characters and is the hexadecimal encoding of the hash digest. Note that the SQL column that /// stores this property allows longer string values to facilitate future hash algorithm changes. /// public string Thumbprint { get; set; } + + /// + /// Gets or sets the collection of user certificates. + /// + public ICollection UserCertificates { get; set; } + + public Certificate() + { + UserCertificates = new List(); + } } -} +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/Entities/EntitiesContext.cs b/src/NuGetGallery.Core/Entities/EntitiesContext.cs index 864540d512..3afe276f43 100644 --- a/src/NuGetGallery.Core/Entities/EntitiesContext.cs +++ b/src/NuGetGallery.Core/Entities/EntitiesContext.cs @@ -48,6 +48,7 @@ public EntitiesContext(string connectionString, bool readOnly) public IDbSet UserSecurityPolicies { get; set; } public IDbSet ReservedNamespaces { get; set; } public IDbSet Certificates { get; set; } + public IDbSet UserCertificates { get; set; } /// /// User or organization accounts. @@ -232,6 +233,13 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) .MapLeftKey("PackageRegistrationKey") .MapRightKey("UserKey")); + modelBuilder.Entity() + .HasMany(pr => pr.RequiredSigners) + .WithMany() + .Map(c => c.ToTable("PackageRegistrationRequiredSigners") + .MapLeftKey("PackageRegistrationKey") + .MapRightKey("UserKey")); + modelBuilder.Entity() .HasKey(p => p.Key); @@ -249,7 +257,10 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) .HasMany(p => p.PackageTypes) .WithRequired(pt => pt.Package) .HasForeignKey(pt => pt.PackageKey); - + + modelBuilder.Entity() + .HasOptional(p => p.Certificate); + modelBuilder.Entity() .HasKey(pm => pm.Key); @@ -332,6 +343,27 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) IsUnique = true, } })); + + modelBuilder.Entity() + .Property(c => c.Sha1Thumbprint) + .HasMaxLength(40) + .HasColumnType("varchar") + .IsOptional(); + + modelBuilder.Entity() + .HasKey(uc => uc.Key); + + modelBuilder.Entity() + .HasMany(u => u.UserCertificates) + .WithRequired(uc => uc.User) + .HasForeignKey(uc => uc.UserKey) + .WillCascadeOnDelete(true); // Deleting a User entity will also delete related UserCertificate entities. + + modelBuilder.Entity() + .HasMany(c => c.UserCertificates) + .WithRequired(uc => uc.Certificate) + .HasForeignKey(uc => uc.CertificateKey) + .WillCascadeOnDelete(true); // Deleting a Certificate entity will also delete related UserCertificate entities. } #pragma warning restore 618 } diff --git a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs index 13cd007ed5..7591f727d2 100644 --- a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs +++ b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs @@ -8,6 +8,7 @@ namespace NuGetGallery { public interface IEntitiesContext { + IDbSet Certificates { get; set; } IDbSet CuratedFeeds { get; set; } IDbSet CuratedPackages { get; set; } IDbSet PackageRegistrations { get; set; } @@ -16,6 +17,7 @@ public interface IEntitiesContext IDbSet Users { get; set; } IDbSet UserSecurityPolicies { get; set; } IDbSet ReservedNamespaces { get; set; } + IDbSet UserCertificates { get; set; } Task SaveChangesAsync(); [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1716:IdentifiersShouldNotMatchKeywords", MessageId = "Set", Justification="This is to match the EF terminology.")] diff --git a/src/NuGetGallery.Core/Entities/Organization.cs b/src/NuGetGallery.Core/Entities/Organization.cs index fa054e67c0..94469aa441 100644 --- a/src/NuGetGallery.Core/Entities/Organization.cs +++ b/src/NuGetGallery.Core/Entities/Organization.cs @@ -25,6 +25,7 @@ public Organization() : this(null) public Organization(string name) : base(name) { Members = new List(); + MemberRequests = new List(); _administrators = new Lazy>( () => Members.Where(m => m.IsAdmin).Select(m => m.Member).ToList()); diff --git a/src/NuGetGallery.Core/Entities/Package.cs b/src/NuGetGallery.Core/Entities/Package.cs index a15906caec..c997e09a56 100644 --- a/src/NuGetGallery.Core/Entities/Package.cs +++ b/src/NuGetGallery.Core/Entities/Package.cs @@ -223,5 +223,15 @@ public bool HasReadMe { /// The package status key, referring to the enum. /// public PackageStatus PackageStatusKey { get; set; } + + /// + /// Gets or sets the foreign key of the certificate used to sign the package. + /// + public int? CertificateKey { get; set; } + + /// + /// Gets or sets the certificate used to sign the package. + /// + public virtual Certificate Certificate { get; set; } } -} +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/Entities/PackageRegistration.cs b/src/NuGetGallery.Core/Entities/PackageRegistration.cs index 8e5f9be0f2..1ddfde5834 100644 --- a/src/NuGetGallery.Core/Entities/PackageRegistration.cs +++ b/src/NuGetGallery.Core/Entities/PackageRegistration.cs @@ -14,6 +14,7 @@ public PackageRegistration() Owners = new HashSet(); Packages = new HashSet(); ReservedNamespaces = new HashSet(); + RequiredSigners = new HashSet(); } [StringLength(CoreConstants.MaxPackageIdLength)] @@ -30,6 +31,11 @@ public PackageRegistration() public virtual ICollection Packages { get; set; } public virtual ICollection ReservedNamespaces { get; set; } + /// + /// Gets or sets required signers for this package registration. + /// + public virtual ICollection RequiredSigners { get; set; } + public int Key { get; set; } } } \ No newline at end of file diff --git a/src/NuGetGallery.Core/Entities/User.cs b/src/NuGetGallery.Core/Entities/User.cs index 9f03b1a99b..0ba866f694 100644 --- a/src/NuGetGallery.Core/Entities/User.cs +++ b/src/NuGetGallery.Core/Entities/User.cs @@ -51,6 +51,7 @@ public User(string username) OrganizationRequests = new List(); Roles = new List(); Username = username; + UserCertificates = new List(); } /// @@ -131,6 +132,11 @@ public string LastSavedEmailAddress public virtual ICollection SecurityPolicies { get; set; } + /// + /// Gets or sets the collection of user certificates. + /// + public virtual ICollection UserCertificates { get; set; } + public void ConfirmEmailAddress() { if (string.IsNullOrEmpty(UnconfirmedEmailAddress)) diff --git a/src/NuGetGallery.Core/Entities/UserCertificate.cs b/src/NuGetGallery.Core/Entities/UserCertificate.cs new file mode 100644 index 0000000000..4e5879a1d8 --- /dev/null +++ b/src/NuGetGallery.Core/Entities/UserCertificate.cs @@ -0,0 +1,40 @@ +// 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.ComponentModel.DataAnnotations.Schema; + +namespace NuGetGallery +{ + /// + /// Represents a relationship between a user and certificate. + /// + public class UserCertificate : IEntity + { + /// + /// Gets or sets the primary key for the entity. + /// + public int Key { get; set; } + + /// + /// Gets or sets the foreign key of the certificate entity. + /// + [Index("IX_UserCertificates_CertificateKeyUserKey", IsUnique = true, Order = 0)] + public int CertificateKey { get; set; } + + /// + /// Gets or sets the certificate entity. + /// + public virtual Certificate Certificate { get; set; } + + /// + /// Gets or sets the foreign key of the user entity. + /// + [Index("IX_UserCertificates_CertificateKeyUserKey", IsUnique = true, Order = 1)] + public int UserKey { get; set; } + + /// + /// Gets or sets the user entity. + /// + public virtual User User { get; set; } + } +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/Extensions/PackageRegistrationExtensions.cs b/src/NuGetGallery.Core/Extensions/PackageRegistrationExtensions.cs new file mode 100644 index 0000000000..e9ef92e27d --- /dev/null +++ b/src/NuGetGallery.Core/Extensions/PackageRegistrationExtensions.cs @@ -0,0 +1,77 @@ +// 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; + +namespace NuGetGallery.Extensions +{ + public static class PackageRegistrationExtensions + { + /// + /// Determines if package signing is required for the specified package registration. + /// + /// A package registration. + /// A flag indicating whether package signing is required. + /// Thrown if + /// is null. + public static bool IsSigningRequired(this PackageRegistration packageRegistration) + { + if (packageRegistration == null) + { + throw new ArgumentNullException(nameof(packageRegistration)); + } + + var requiredSigner = packageRegistration.RequiredSigners.FirstOrDefault(); + + if (requiredSigner == null) + { + return packageRegistration.Owners.All(HasAnyCertificate); + } + + return HasAnyCertificate(requiredSigner); + } + + /// + /// Determines if the certificate with specified thumbprint is valid for signing the specified package registration. + /// + /// A package registration. + /// A certificate thumbprint. + /// A flag indicating whether the certificate is acceptable for signing. + /// Thrown if + /// is null. + /// Thrown if is null + /// or empty. + public static bool IsAcceptableSigningCertificate(this PackageRegistration packageRegistration, string thumbprint) + { + if (packageRegistration == null) + { + throw new ArgumentNullException(nameof(packageRegistration)); + } + + if (string.IsNullOrWhiteSpace(thumbprint)) + { + throw new ArgumentException(CoreStrings.ArgumentCannotBeNullOrEmpty, nameof(thumbprint)); + } + + var requiredSigner = packageRegistration.RequiredSigners.FirstOrDefault(); + + if (requiredSigner == null) + { + return packageRegistration.Owners.Any(owner => CanUseCertificate(owner, thumbprint)); + } + + return CanUseCertificate(requiredSigner, thumbprint); + } + + private static bool HasAnyCertificate(User user) + { + return user.UserCertificates.Any(); + } + + private static bool CanUseCertificate(User user, string thumbprint) + { + return user.UserCertificates.Any(uc => string.Equals(uc.Certificate.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)); + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/NuGetGallery.Core.csproj b/src/NuGetGallery.Core/NuGetGallery.Core.csproj index ecd91e9a93..b88652b6d6 100644 --- a/src/NuGetGallery.Core/NuGetGallery.Core.csproj +++ b/src/NuGetGallery.Core/NuGetGallery.Core.csproj @@ -125,7 +125,9 @@ + + @@ -159,6 +161,7 @@ + @@ -211,9 +214,11 @@ + + @@ -244,6 +249,7 @@ + diff --git a/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs b/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs index 522860f481..dce44e8de1 100644 --- a/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs +++ b/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs @@ -37,7 +37,8 @@ public class CloudBlobCoreFileStorageService : ICoreFileStorageService CoreConstants.ContentFolderName, CoreConstants.UploadsFolderName, CoreConstants.PackageReadMesFolderName, - CoreConstants.ValidationFolderName + CoreConstants.ValidationFolderName, + CoreConstants.UserCertificatesFolderName }; protected readonly ICloudBlobClient _client; @@ -233,7 +234,7 @@ await destBlob.StartCopyAsync( } catch (StorageException ex) when (ex.RequestInformation?.HttpStatusCode == (int?)HttpStatusCode.Conflict) { - throw new InvalidOperationException( + throw new FileAlreadyExistsException( String.Format( CultureInfo.CurrentCulture, "There is already a blob with name {0} in container {1}.", @@ -287,7 +288,7 @@ public async Task SaveFileAsync(string folderName, string fileName, Stream packa } catch (StorageException ex) when (ex.RequestInformation?.HttpStatusCode == (int?)HttpStatusCode.Conflict) { - throw new InvalidOperationException( + throw new FileAlreadyExistsException( String.Format( CultureInfo.CurrentCulture, "There is already a blob with name {0} in container {1}.", diff --git a/src/NuGetGallery.Core/Services/CoreMessageService.cs b/src/NuGetGallery.Core/Services/CoreMessageService.cs index 420493f98e..cd0245ed6d 100644 --- a/src/NuGetGallery.Core/Services/CoreMessageService.cs +++ b/src/NuGetGallery.Core/Services/CoreMessageService.cs @@ -26,27 +26,15 @@ public CoreMessageService(IMailSender mailSender, ICoreMessageServiceConfigurati public void SendPackageAddedNotice(Package package, string packageUrl, string packageSupportUrl, string emailSettingsUrl) { - string subject = "[{0}] Package published - {1} {2}"; - string body = @"The package [{1} {2}]({3}) was just published on {0}. If this was not intended, please [contact support]({4}). + string subject = $"[{CoreConfiguration.GalleryOwner.DisplayName}] Package published - {package.PackageRegistration.Id} {package.Version}"; + string body = $@"The package [{package.PackageRegistration.Id} {package.Version}]({packageUrl}) was recently published on {CoreConfiguration.GalleryOwner.DisplayName} by {package.User.Username}. If this was not intended, please [contact support]({packageSupportUrl}). ----------------------------------------------- - To stop receiving emails as an owner of this package, sign in to the {0} and - [change your email notification settings]({5}). + To stop receiving emails as an owner of this package, sign in to the {CoreConfiguration.GalleryOwner.DisplayName} and + [change your email notification settings]({emailSettingsUrl}). "; - body = string.Format( - CultureInfo.CurrentCulture, - body, - CoreConfiguration.GalleryOwner.DisplayName, - package.PackageRegistration.Id, - package.Version, - packageUrl, - packageSupportUrl, - emailSettingsUrl); - - subject = string.Format(CultureInfo.CurrentCulture, subject, CoreConfiguration.GalleryOwner.DisplayName, package.PackageRegistration.Id, package.Version); - using (var mailMessage = new MailMessage()) { mailMessage.Subject = subject; diff --git a/src/NuGetGallery.Core/Services/CorePackageFileService.cs b/src/NuGetGallery.Core/Services/CorePackageFileService.cs index 8d8cf665a8..4a9f757709 100644 --- a/src/NuGetGallery.Core/Services/CorePackageFileService.cs +++ b/src/NuGetGallery.Core/Services/CorePackageFileService.cs @@ -182,7 +182,7 @@ public async Task StorePackageFileInBackupLocationAsync(Package package, Stream { await _fileStorageService.SaveFileAsync(CoreConstants.PackageBackupsFolderName, fileName, packageFile); } - catch (InvalidOperationException) + catch (FileAlreadyExistsException) { // If the package file already exists, swallow the exception since we know the content is the same. return; diff --git a/src/NuGetGallery.Core/Services/CorePackageService.cs b/src/NuGetGallery.Core/Services/CorePackageService.cs index e0a92ecaf5..bbbbe1c8b2 100644 --- a/src/NuGetGallery.Core/Services/CorePackageService.cs +++ b/src/NuGetGallery.Core/Services/CorePackageService.cs @@ -12,11 +12,18 @@ namespace NuGetGallery { public class CorePackageService : ICorePackageService { + protected readonly IEntityRepository _certificateRepository; protected readonly IEntityRepository _packageRepository; - - public CorePackageService(IEntityRepository packageRepository) + protected readonly IEntityRepository _packageRegistrationRepository; + + public CorePackageService( + IEntityRepository packageRepository, + IEntityRepository packageRegistrationRepository, + IEntityRepository certificateRepository) { _packageRepository = packageRepository ?? throw new ArgumentNullException(nameof(packageRepository)); + _packageRegistrationRepository = packageRegistrationRepository ?? throw new ArgumentNullException(nameof(packageRegistrationRepository)); + _certificateRepository = certificateRepository ?? throw new ArgumentNullException(nameof(certificateRepository)); } public virtual async Task UpdatePackageStreamMetadataAsync( @@ -222,6 +229,61 @@ public virtual Package FindPackageByIdAndVersionStrict(string id, string version return package; } + public virtual PackageRegistration FindPackageRegistrationById(string packageId) + { + if (string.IsNullOrWhiteSpace(packageId)) + { + throw new ArgumentException(CoreStrings.ArgumentCannotBeNullOrEmpty, nameof(packageId)); + } + + return _packageRegistrationRepository.GetAll() + .Include(pr => pr.Owners.Select(o => o.UserCertificates)) + .Include(pr => pr.RequiredSigners.Select(rs => rs.UserCertificates)) + .Where(registration => registration.Id == packageId) + .SingleOrDefault(); + } + + public virtual async Task UpdatePackageSigningCertificateAsync(string packageId, string packageVersion, string thumbprint) + { + if (string.IsNullOrEmpty(packageId)) + { + throw new ArgumentException(CoreStrings.ArgumentCannotBeNullOrEmpty, nameof(packageId)); + } + + if (string.IsNullOrEmpty(packageVersion)) + { + throw new ArgumentException(CoreStrings.ArgumentCannotBeNullOrEmpty, nameof(packageVersion)); + } + + if (string.IsNullOrEmpty(thumbprint)) + { + throw new ArgumentException(CoreStrings.ArgumentCannotBeNullOrEmpty, nameof(thumbprint)); + } + + var package = FindPackageByIdAndVersionStrict(packageId, packageVersion); + + if (package == null) + { + throw new ArgumentException(CoreStrings.PackageNotFound); + } + + var certificate = _certificateRepository.GetAll() + .Where(c => c.Thumbprint == thumbprint) + .SingleOrDefault(); + + if (certificate == null) + { + throw new ArgumentException(CoreStrings.CertificateNotFound); + } + + if (package.CertificateKey != certificate.Key) + { + package.Certificate = certificate; + + await _packageRepository.CommitChangesAsync(); + } + } + protected IQueryable GetPackagesByIdQueryable(string id) { return _packageRepository @@ -248,4 +310,4 @@ private static Package FindPackage(IQueryable packages, Func pv.Version.Equals(version.OriginalVersion, StringComparison.OrdinalIgnoreCase)); } } -} +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/Services/FileAlreadyExistsException.cs b/src/NuGetGallery.Core/Services/FileAlreadyExistsException.cs new file mode 100644 index 0000000000..85941c7d9d --- /dev/null +++ b/src/NuGetGallery.Core/Services/FileAlreadyExistsException.cs @@ -0,0 +1,19 @@ +// 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 System.Text; +using System.Threading.Tasks; + +namespace NuGetGallery +{ + [Serializable] + public sealed class FileAlreadyExistsException : Exception + { + public FileAlreadyExistsException() { } + public FileAlreadyExistsException(string message) : base(message) { } + public FileAlreadyExistsException(string message, Exception inner) : base(message, inner) { } + } +} \ No newline at end of file diff --git a/src/NuGetGallery.Core/Services/ICorePackageService.cs b/src/NuGetGallery.Core/Services/ICorePackageService.cs index 66799cfd39..370eb1d041 100644 --- a/src/NuGetGallery.Core/Services/ICorePackageService.cs +++ b/src/NuGetGallery.Core/Services/ICorePackageService.cs @@ -42,5 +42,27 @@ public interface ICorePackageService /// The package registration. /// Whether or not to commit the changes to the packages. Task UpdateIsLatestAsync(PackageRegistration packageRegistration, bool commitChanges = true); + + /// + /// Updates the . + /// + /// The package key. + /// The package key. + /// The certificate thumbprint. + /// Thrown if is null or empty. + /// Thrown if is null or empty. + /// Thrown if the package does not exist. + /// Thrown if is null or empty + /// or a certificate with the specified thumbprint does not exist. + Task UpdatePackageSigningCertificateAsync(string packageId, string packageVersion, string thumbprint); + + /// + /// Gets the package registration with the specified ID when it exists; otherwise, null. + /// + /// The package ID. + /// A package registration or null. + /// Thrown if is null + /// or empty. + PackageRegistration FindPackageRegistrationById(string packageId); } } \ No newline at end of file diff --git a/src/NuGetGallery/App_Code/ViewHelpers.cshtml b/src/NuGetGallery/App_Code/ViewHelpers.cshtml index 08a3606a07..d892142535 100644 --- a/src/NuGetGallery/App_Code/ViewHelpers.cshtml +++ b/src/NuGetGallery/App_Code/ViewHelpers.cshtml @@ -1,4 +1,5 @@ @using System.Web.Mvc +@using System.Web.Routing @using System.Web.Mvc.Html @using Microsoft.Web.Helpers @using NuGetGallery @@ -251,7 +252,7 @@ { } - @user.Username + @user.Username.Abbreviate(15) } @@ -353,6 +354,7 @@ var cookieService = DependencyResolver.Current.GetService(); if (cookieService.CanWriteNonEssentialCookies(Request)) { + var obfuscatedRequest = ObfuscationHelper.ObfuscateRequestUrl(new HttpContextWrapper(HttpContext.Current), RouteTable.Routes); } @@ -450,7 +455,7 @@ var hlp = new AccordionHelper(name, formModelStatePrefix, expanded, page); @helper AjaxAntiForgeryToken(System.Web.Mvc.HtmlHelper html) { -
+ @html.AntiForgeryToken()
} @@ -532,7 +537,7 @@ var hlp = new AccordionHelper(name, formModelStatePrefix, expanded, page); @if (!disabled) { + aria-expanded="@(expanded ? "true" : "false")" aria-controls="@id-container" id="show-@id-container"> @title(MvcHtmlString.Empty) diff --git a/src/NuGetGallery/App_Start/AppActivator.cs b/src/NuGetGallery/App_Start/AppActivator.cs index ee94934aea..b916b1f92c 100644 --- a/src/NuGetGallery/App_Start/AppActivator.cs +++ b/src/NuGetGallery/App_Start/AppActivator.cs @@ -129,8 +129,7 @@ private static void BundlingPostStart() .Include("~/Scripts/jquery.validate.js") .Include("~/Scripts/jquery.validate.unobtrusive.js") .Include("~/Scripts/jquery.timeago.js") - .Include("~/Scripts/nugetgallery.js") - .Include("~/Scripts/stats.js"); + .Include("~/Scripts/nugetgallery.js"); BundleTable.Bundles.Add(scriptBundle); // Modernizr needs to be delivered at the top of the page but putting it in a bundle gets us a cache-buster. @@ -149,6 +148,7 @@ private static void BundlingPostStart() { stylesBundle .Include("~/Content/" + filename) + .Include("~/Content/Branding/" + filename) .Include("~/Branding/Content/" + filename); } diff --git a/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs b/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs index 60962cfd63..a6c50718b4 100644 --- a/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs +++ b/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs @@ -139,6 +139,11 @@ protected override void Load(ContainerBuilder builder) .As>() .InstancePerLifetimeScope(); + builder.RegisterType>() + .AsSelf() + .As>() + .InstancePerLifetimeScope(); + builder.RegisterType>() .AsSelf() .As>() @@ -198,7 +203,7 @@ protected override void Load(ContainerBuilder builder) .AsSelf() .As() .InstancePerLifetimeScope(); - + builder.RegisterType() .AsSelf() .As() @@ -251,12 +256,22 @@ protected override void Load(ContainerBuilder builder) .AsSelf() .As() .InstancePerLifetimeScope(); - + builder.RegisterType() .AsSelf() .As() .SingleInstance(); + builder.RegisterType() + .AsSelf() + .As() + .SingleInstance(); + + builder.RegisterType() + .AsSelf() + .As() + .InstancePerLifetimeScope(); + if (configuration.Current.IsHosted) { HostingEnvironment.QueueBackgroundWorkItem(async cancellationToken => await DependencyResolver @@ -265,8 +280,7 @@ protected override void Load(ContainerBuilder builder) .Refresh()); } - var mailSenderThunk = new Lazy( - () => + Func mailSenderFactory = () => { var settings = configuration; if (settings.Current.SmtpUri != null && settings.Current.SmtpUri.IsAbsoluteUri) @@ -301,9 +315,9 @@ protected override void Load(ContainerBuilder builder) return new MailSender(mailSenderConfiguration); } - }); + }; - builder.Register(c => mailSenderThunk.Value) + builder.Register(c => mailSenderFactory()) .AsSelf() .As() .InstancePerLifetimeScope(); diff --git a/src/NuGetGallery/App_Start/Routes.cs b/src/NuGetGallery/App_Start/Routes.cs index 22d0422e0e..4c4eb11e21 100644 --- a/src/NuGetGallery/App_Start/Routes.cs +++ b/src/NuGetGallery/App_Start/Routes.cs @@ -144,6 +144,13 @@ public static void RegisterUIRoutes(RouteCollection routes) "packages/manage/cancel-upload", new { controller = "Packages", action = "CancelUpload" }); + routes.MapRoute( + RouteName.SetRequiredSigner, + "packages/{id}/required-signer/{username}", + new { controller = "Packages", action = RouteName.SetRequiredSigner, username = UrlParameter.Optional }, + constraints: new { httpMethod = new HttpMethodConstraint("POST") }, + obfuscationMetadata: new RouteExtensions.ObfuscatedMetadata(3, Obfuscator.DefaultTelemetryUserName) ); + routes.MapRoute( RouteName.PackageOwnerConfirmation, "packages/{id}/owners/{username}/confirm/{token}", @@ -254,6 +261,30 @@ public static void RegisterUIRoutes(RouteCollection routes) new { controller = "Users", action = "Profiles" }, new RouteExtensions.ObfuscatedMetadata(1, Obfuscator.DefaultTelemetryUserName)); + routes.MapRoute( + RouteName.GetUserCertificate, + "account/certificates/{thumbprint}", + new { controller = "Users", action = "GetCertificate" }, + constraints: new { httpMethod = new HttpMethodConstraint("GET") }); + + routes.MapRoute( + RouteName.DeleteUserCertificate, + "account/certificates/{thumbprint}", + new { controller = "Users", action = "DeleteCertificate" }, + constraints: new { httpMethod = new HttpMethodConstraint("DELETE") }); + + routes.MapRoute( + RouteName.GetUserCertificates, + "account/certificates", + new { controller = "Users", action = "GetCertificates" }, + constraints: new { httpMethod = new HttpMethodConstraint("GET") }); + + routes.MapRoute( + RouteName.AddUserCertificate, + "account/certificates", + new { controller = "Users", action = "AddCertificate" }, + constraints: new { httpMethod = new HttpMethodConstraint("POST") }); + routes.MapRoute( RouteName.RemovePassword, "account/RemoveCredential/password", @@ -281,12 +312,17 @@ public static void RegisterUIRoutes(RouteCollection routes) "account/confirm/{accountName}/{token}", new { controller = "Users", action = "Confirm" }, new RouteExtensions.ObfuscatedMetadata(2, Obfuscator.DefaultTelemetryUserName)); - + routes.MapRoute( RouteName.ChangeEmailSubscription, "account/subscription/change", new { controller = "Users", action = "ChangeEmailSubscription" }); + routes.MapRoute( + RouteName.ChangeMultiFactorAuthentication, + "account/changeMultiFactorAuthentication", + new { controller = "Users", action = "ChangeMultiFactorAuthentication" }); + routes.MapRoute( RouteName.AdminDeleteAccount, "account/delete/{accountName}", @@ -335,6 +371,34 @@ public static void RegisterUIRoutes(RouteCollection routes) "organization/add", new { controller = "Organizations", action = "Add" }); + routes.MapRoute( + RouteName.GetOrganizationCertificate, + "organization/{accountName}/certificates/{thumbprint}", + new { controller = "Organizations", action = "GetCertificate" }, + constraints: new { httpMethod = new HttpMethodConstraint("GET") }, + obfuscationMetadata: new RouteExtensions.ObfuscatedMetadata(1, Obfuscator.DefaultTelemetryUserName)); + + routes.MapRoute( + RouteName.DeleteOrganizationCertificate, + "organization/{accountName}/certificates/{thumbprint}", + new { controller = "Organizations", action = "DeleteCertificate" }, + constraints: new { httpMethod = new HttpMethodConstraint("DELETE") }, + obfuscationMetadata: new RouteExtensions.ObfuscatedMetadata(1, Obfuscator.DefaultTelemetryUserName)); + + routes.MapRoute( + RouteName.GetOrganizationCertificates, + "organization/{accountName}/certificates", + new { controller = "Organizations", action = "GetCertificates" }, + constraints: new { httpMethod = new HttpMethodConstraint("GET") }, + obfuscationMetadata: new RouteExtensions.ObfuscatedMetadata(1, Obfuscator.DefaultTelemetryUserName)); + + routes.MapRoute( + RouteName.AddOrganizationCertificate, + "organization/{accountName}/certificates", + new { controller = "Organizations", action = "AddCertificate" }, + constraints: new { httpMethod = new HttpMethodConstraint("POST") }, + obfuscationMetadata: new RouteExtensions.ObfuscatedMetadata(1, Obfuscator.DefaultTelemetryUserName)); + routes.MapRoute( RouteName.OrganizationMemberAddAjax, "organization/{accountName}/members/add", diff --git a/src/NuGetGallery/App_Start/StorageDependent.cs b/src/NuGetGallery/App_Start/StorageDependent.cs index ed621f20b0..c9e31a3ebd 100644 --- a/src/NuGetGallery/App_Start/StorageDependent.cs +++ b/src/NuGetGallery/App_Start/StorageDependent.cs @@ -84,6 +84,7 @@ public static IEnumerable GetAll(IAppConfiguration configurati /// This array must be added to as we implement more services that use . var dependents = new[] { + Create(configuration.AzureStorage_UserCertificates_ConnectionString, isSingleInstance: false), Create(configuration.AzureStorage_Content_ConnectionString, isSingleInstance: true), Create(configuration.AzureStorage_Packages_ConnectionString, isSingleInstance: false), Create(configuration.AzureStorage_Uploads_ConnectionString, isSingleInstance: false), diff --git a/src/NuGetGallery/Areas/Admin/ViewModels/DeleteUserAccountViewModel.cs b/src/NuGetGallery/Areas/Admin/ViewModels/DeleteAccountAsAdminViewModel.cs similarity index 55% rename from src/NuGetGallery/Areas/Admin/ViewModels/DeleteUserAccountViewModel.cs rename to src/NuGetGallery/Areas/Admin/ViewModels/DeleteAccountAsAdminViewModel.cs index 4df92987b0..5a79b9dac7 100644 --- a/src/NuGetGallery/Areas/Admin/ViewModels/DeleteUserAccountViewModel.cs +++ b/src/NuGetGallery/Areas/Admin/ViewModels/DeleteAccountAsAdminViewModel.cs @@ -5,20 +5,29 @@ namespace NuGetGallery.Areas.Admin.ViewModels { - public class DeleteUserAccountViewModel : DeleteAccountViewModel + public class DeleteAccountAsAdminViewModel { - public DeleteUserAccountViewModel() + public DeleteAccountAsAdminViewModel() { ShouldUnlist = true; } + public DeleteAccountAsAdminViewModel(IDeleteAccountViewModel model) + { + AccountName = model.AccountName; + HasOrphanPackages = model.HasOrphanPackages; + } + + public string AccountName { get; set; } + [Required(ErrorMessage = "Please sign using your name.")] [StringLength(1000)] [Display(Name = "Signature")] public string Signature { get; set; } - [Display(Name = "Unlist the packages with no other owners.")] + [Display(Name = "Unlist any orphaned packages.")] public bool ShouldUnlist { get; set; } - } + public bool HasOrphanPackages { get; set; } + } } diff --git a/src/NuGetGallery/Areas/Admin/Views/DeleteAccount/DeleteUserAccount.cshtml b/src/NuGetGallery/Areas/Admin/Views/DeleteAccount/DeleteUserAccount.cshtml index 3eb2563d36..be6f9f7924 100644 --- a/src/NuGetGallery/Areas/Admin/Views/DeleteAccount/DeleteUserAccount.cshtml +++ b/src/NuGetGallery/Areas/Admin/Views/DeleteAccount/DeleteUserAccount.cshtml @@ -1,5 +1,5 @@ @using NuGetGallery -@model NuGetGallery.Areas.Admin.ViewModels.DeleteUserAccountViewModel +@model DeleteUserViewModel @{ ViewBag.Title = "Delete Account " + Model.AccountName; ViewBag.MdPageColumns = Constants.ColumnsFormMd; @@ -17,51 +17,17 @@ 1. Revoke associated API key(s).
2. Remove the account as an owner for any child packages.
3. Dissociate all previously existent ID prefix reservations with this account.
+ 4. Remove the account as a member of any organizations.
)
- @{ - @Html.Partial("_UserPackagesListForDeletedAccount", new ManagePackagesListViewModel(@Model.Packages, "Packages")) - } + @Html.Partial("_UserPackagesListForDeletedAccount", Model) +
+
+ @Html.Partial("_UserOrganizationsListForDeletedAccount", Model.Organizations)
-
- @using (Html.BeginForm("Delete", "Users", FormMethod.Post, new { id = "delete-form" })) - { - @Html.HttpMethodOverride(HttpVerbs.Delete) - @Html.AntiForgeryToken() - @Html.ValidationSummary(true) -
-
- @Html.LabelFor(m => m.Signature) - @Html.EditorFor(m => m.Signature) - @Html.ValidationMessageFor(m => m.Signature) -
- - @if (Model.HasOrphanPackages) - { -
- @Html.EditorFor(m => m.ShouldUnlist) - -

- One or more packages do not have co-owners. If you choose to proceed without fixing this issue, these packages will be orphaned and a warning message will be displayed under owners on the package page. -

-
- } -
-
-

- This action CANNOT be undone. This will permanently delete the account @Model.AccountName. -

-
- @Html.HiddenFor(m => m.Signature) - @Html.HiddenFor(m => m.ShouldUnlist) - @Html.HiddenFor(m => m.AccountName) - - } -
+ @Html.Partial("_DeleteUserAccountForm", new DeleteAccountAsAdminViewModel(Model)) diff --git a/src/NuGetGallery/Areas/Admin/Views/DeleteAccount/_DeleteUserAccountForm.cshtml b/src/NuGetGallery/Areas/Admin/Views/DeleteAccount/_DeleteUserAccountForm.cshtml new file mode 100644 index 0000000000..a6d9b90c98 --- /dev/null +++ b/src/NuGetGallery/Areas/Admin/Views/DeleteAccount/_DeleteUserAccountForm.cshtml @@ -0,0 +1,39 @@ +@model DeleteAccountAsAdminViewModel + +
+ @using (Html.BeginForm("Delete", "Users", FormMethod.Post, new { id = "delete-form" })) + { + @Html.HttpMethodOverride(HttpVerbs.Delete) + @Html.AntiForgeryToken() + @Html.ValidationSummary(true) +
+
+ @Html.LabelFor(m => m.Signature) + @Html.EditorFor(m => m.Signature) + @Html.ValidationMessageFor(m => m.Signature) +
+ + @if (Model.HasOrphanPackages) + { +
+ @Html.EditorFor(m => m.ShouldUnlist) + +

+ One or more packages do not have co-owners. If you choose to proceed without fixing this issue, these packages will be orphaned and a warning message will be displayed under owners on the package page. +

+
+ } +
+
+

+ This action CANNOT be undone. You will not be able to regain access to the account @Model.AccountName or any of its packages. +

+
+ @Html.HiddenFor(m => m.Signature) + @Html.HiddenFor(m => m.ShouldUnlist) + @Html.HiddenFor(m => m.AccountName) + + } +
\ No newline at end of file diff --git a/src/NuGetGallery/Authentication/AuthenticatedUser.cs b/src/NuGetGallery/Authentication/AuthenticatedUser.cs index a8d6839e10..3611220fb9 100644 --- a/src/NuGetGallery/Authentication/AuthenticatedUser.cs +++ b/src/NuGetGallery/Authentication/AuthenticatedUser.cs @@ -5,6 +5,13 @@ namespace NuGetGallery.Authentication { + public class LoginUserDetails + { + public AuthenticatedUser AuthenticatedUser { get; set; } + + public bool UsedMultiFactorAuthentication { get; set; } + } + public class AuthenticatedUser { public User User { get; private set; } @@ -21,7 +28,7 @@ public AuthenticatedUser(User user, Credential cred) { throw new ArgumentNullException(nameof(cred)); } - + User = user; CredentialUsed = cred; } diff --git a/src/NuGetGallery/Authentication/AuthenticationService.cs b/src/NuGetGallery/Authentication/AuthenticationService.cs index e3f8ff2813..aa98a8f132 100644 --- a/src/NuGetGallery/Authentication/AuthenticationService.cs +++ b/src/NuGetGallery/Authentication/AuthenticationService.cs @@ -233,10 +233,15 @@ await Auditing.SaveAuditRecordAsync( } } - public virtual async Task CreateSessionAsync(IOwinContext owinContext, AuthenticatedUser user) + /// + /// Generate the new session for the logged in user. Also, set the appropriate claims for the user in this session. + /// The multi-factor authentication setting value can be obtained from external logins(in case of AADv2). + /// + /// Awaitable task + public virtual async Task CreateSessionAsync(IOwinContext owinContext, AuthenticatedUser authenticatedUser, bool wasMultiFactorAuthenticated = false) { // Create a claims identity for the session - ClaimsIdentity identity = CreateIdentity(user.User, AuthenticationTypes.LocalUser, await GetDiscontinuedLoginClaims(user)); + ClaimsIdentity identity = CreateIdentity(authenticatedUser.User, AuthenticationTypes.LocalUser, await GetUserLoginClaims(authenticatedUser, wasMultiFactorAuthenticated)); // Issue the session token and clean up the external token if present owinContext.Authentication.SignIn(identity); @@ -244,10 +249,10 @@ public virtual async Task CreateSessionAsync(IOwinContext owinContext, Authentic // Write an audit record await Auditing.SaveAuditRecordAsync( - new UserAuditRecord(user.User, AuditedUserAction.Login, user.CredentialUsed)); + new UserAuditRecord(authenticatedUser.User, AuditedUserAction.Login, authenticatedUser.CredentialUsed)); } - private async Task GetDiscontinuedLoginClaims(AuthenticatedUser user) + private async Task GetUserLoginClaims(AuthenticatedUser user, bool wasMultiFactorAuthenticated) { await _contentObjectService.Refresh(); @@ -268,6 +273,18 @@ private async Task GetDiscontinuedLoginClaims(AuthenticatedUser user) ClaimsExtensions.AddBooleanClaim(claims, NuGetClaims.ExternalLogin); } + if (user.User.EnableMultiFactorAuthentication) + { + ClaimsExtensions.AddBooleanClaim(claims, NuGetClaims.EnabledMultiFactorAuthentication); + } + + if (wasMultiFactorAuthenticated) + { + ClaimsExtensions.AddBooleanClaim(claims, NuGetClaims.WasMultiFactorAuthenticated); + } + + ClaimsExtensions.AddExternalLoginCredentialTypeClaim(claims, user.CredentialUsed.Type); + return claims.ToArray(); } diff --git a/src/NuGetGallery/Configuration/AppConfiguration.cs b/src/NuGetGallery/Configuration/AppConfiguration.cs index 8af24ca018..47c4642769 100644 --- a/src/NuGetGallery/Configuration/AppConfiguration.cs +++ b/src/NuGetGallery/Configuration/AppConfiguration.cs @@ -38,10 +38,12 @@ public class AppConfiguration : IAppConfiguration [TypeConverter(typeof(StringArrayConverter))] public string[] ForceSslExclusion { get; set; } - [DisplayName("AzureStorage.Auditing.ConnectionString")] public string AzureStorage_Auditing_ConnectionString { get; set; } + [DisplayName("AzureStorage.UserCertificates.ConnectionString")] + public string AzureStorage_UserCertificates_ConnectionString { get; set; } + [DisplayName("AzureStorage.Content.ConnectionString")] public string AzureStorage_Content_ConnectionString { get; set; } diff --git a/src/NuGetGallery/Configuration/IAppConfiguration.cs b/src/NuGetGallery/Configuration/IAppConfiguration.cs index 4606365b4c..f8320ac9cd 100644 --- a/src/NuGetGallery/Configuration/IAppConfiguration.cs +++ b/src/NuGetGallery/Configuration/IAppConfiguration.cs @@ -44,7 +44,12 @@ public interface IAppConfiguration : ICoreMessageServiceConfiguration /// The Azure Storage connection string used for auditing. ///
string AzureStorage_Auditing_ConnectionString { get; set; } - + + /// + /// The Azure Storage connection string used for user certificates. + /// + string AzureStorage_UserCertificates_ConnectionString { get; set; } + /// /// The Azure Storage connection string used for static content. /// diff --git a/src/NuGetGallery/Controllers/AccountsController.cs b/src/NuGetGallery/Controllers/AccountsController.cs index 5dccc45036..5a97e3fc53 100644 --- a/src/NuGetGallery/Controllers/AccountsController.cs +++ b/src/NuGetGallery/Controllers/AccountsController.cs @@ -4,17 +4,19 @@ using System; using System.Linq; using System.Net; -using System.Net.Mail; using System.Threading.Tasks; +using System.Web; using System.Web.Mvc; using NuGetGallery.Authentication; using NuGetGallery.Filters; +using NuGetGallery.Helpers; +using NuGetGallery.Security; namespace NuGetGallery { public abstract class AccountsController : AppController where TUser : User - where TAccountViewModel : AccountViewModel + where TAccountViewModel : AccountViewModel { public class ViewMessages { @@ -29,24 +31,36 @@ public class ViewMessages public ICuratedFeedService CuratedFeedService { get; } + public IPackageService PackageService { get; } + public IMessageService MessageService { get; } public IUserService UserService { get; } public ITelemetryService TelemetryService { get; } + public ISecurityPolicyService SecurityPolicyService { get; } + + public ICertificateService CertificateService { get; } + public AccountsController( AuthenticationService authenticationService, ICuratedFeedService curatedFeedService, + IPackageService packageService, IMessageService messageService, IUserService userService, - ITelemetryService telemetryService) + ITelemetryService telemetryService, + ISecurityPolicyService securityPolicyService, + ICertificateService certificateService) { AuthenticationService = authenticationService ?? throw new ArgumentNullException(nameof(authenticationService)); CuratedFeedService = curatedFeedService ?? throw new ArgumentNullException(nameof(curatedFeedService)); + PackageService = packageService ?? throw new ArgumentNullException(nameof(packageService)); MessageService = messageService ?? throw new ArgumentNullException(nameof(messageService)); UserService = userService ?? throw new ArgumentNullException(nameof(userService)); TelemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); + SecurityPolicyService = securityPolicyService ?? throw new ArgumentNullException(nameof(securityPolicyService)); + CertificateService = certificateService ?? throw new ArgumentNullException(nameof(certificateService)); } public abstract string AccountAction { get; } @@ -84,7 +98,7 @@ public virtual ActionResult ConfirmationRequiredPost(string accountName = null) { return new HttpStatusCodeResult(HttpStatusCode.Forbidden, Strings.Unauthorized); } - + var alreadyConfirmed = account.UnconfirmedEmailAddress == null; ConfirmationViewModel model; @@ -201,14 +215,14 @@ public virtual async Task ChangeEmail(TAccountViewModel model) { return AccountView(account, model); } - + if (account.HasPasswordCredential()) { if (!ModelState.IsValidField("ChangeEmail.Password")) { return AccountView(account, model); } - + if (!AuthenticationService.ValidatePasswordCredential(account.Credentials, model.ChangeEmail.Password, out var _)) { ModelState.AddModelError("ChangeEmail.Password", Strings.CurrentPasswordIncorrect); @@ -258,18 +272,40 @@ public virtual async Task CancelChangeEmail(TAccountViewModel mode return new HttpStatusCodeResult(HttpStatusCode.Forbidden, Strings.Unauthorized); } - if (string.IsNullOrWhiteSpace(account.UnconfirmedEmailAddress)) + if (!string.IsNullOrWhiteSpace(account.UnconfirmedEmailAddress)) { - return RedirectToAction(AccountAction); + await UserService.CancelChangeEmailAddress(account); + + TempData["Message"] = Messages.EmailUpdateCancelled; } - await UserService.CancelChangeEmailAddress(account); + return RedirectToAction(AccountAction); + } + + [HttpGet] + [UIAuthorize] + public virtual ActionResult DeleteRequest(string accountName = null) + { + var accountToDelete = GetAccount(accountName); - TempData["Message"] = Messages.EmailUpdateCancelled; + if (accountToDelete == null || accountToDelete.IsDeleted) + { + return HttpNotFound(); + } - return RedirectToAction(AccountAction); + if (ActionsRequiringPermissions.ManageAccount.CheckPermissions(GetCurrentUser(), accountToDelete) + != PermissionsCheckResult.Allowed) + { + return HttpNotFound(); + } + + return View("DeleteAccount", GetDeleteAccountViewModel(accountToDelete)); } + protected abstract DeleteAccountViewModel GetDeleteAccountViewModel(TUser account); + + public abstract Task RequestAccountDeletion(string accountName = null); + protected virtual TUser GetAccount(string accountName) { return UserService.FindByUsername(accountName) as TUser; @@ -285,7 +321,7 @@ protected virtual ActionResult AccountView(TUser account, TAccountViewModel mode } model = model ?? Activator.CreateInstance(); - + UpdateAccountViewModel(account, model); return View(AccountAction, model); @@ -299,12 +335,14 @@ protected virtual void UpdateAccountViewModel(TUser account, TAccountViewModel m model.CanManage = ActionsRequiringPermissions.ManageAccount.CheckPermissions( GetCurrentUser(), account) == PermissionsCheckResult.Allowed; + model.WasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated(); + model.CuratedFeeds = CuratedFeedService .GetFeedsForManager(account.Key) .Select(f => f.Name) .ToList(); - model.HasPassword = account.Credentials.Any(c => c.Type.StartsWith(CredentialTypes.Password.Prefix)); + model.HasPassword = account.Credentials.Any(c => c.IsPassword()); model.CurrentEmailAddress = account.UnconfirmedEmailAddress ?? account.EmailAddress; model.HasConfirmedEmailAddress = !string.IsNullOrEmpty(account.EmailAddress); model.HasUnconfirmedEmailAddress = !string.IsNullOrEmpty(account.UnconfirmedEmailAddress); @@ -315,5 +353,195 @@ protected virtual void UpdateAccountViewModel(TUser account, TAccountViewModel m model.ChangeNotifications.EmailAllowed = account.EmailAllowed; model.ChangeNotifications.NotifyPackagePushed = account.NotifyPackagePushed; } + + [HttpPost] + [UIAuthorize] + [ValidateAntiForgeryToken] + [RequiresAccountConfirmation("add a certificate")] + public virtual async Task AddCertificate(string accountName, HttpPostedFileBase uploadFile) + { + if (uploadFile == null) + { + return Json(HttpStatusCode.BadRequest, new[] { Strings.CertificateFileIsRequired }); + } + + var currentUser = GetCurrentUser(); + var account = GetAccount(accountName); + + if (currentUser == null) + { + return Json(HttpStatusCode.Unauthorized); + } + + if (account == null) + { + return Json(HttpStatusCode.NotFound); + } + + if (ActionsRequiringPermissions.ManageAccount.CheckPermissions(currentUser, account) + != PermissionsCheckResult.Allowed || !User.WasMultiFactorAuthenticated()) + { + return Json(HttpStatusCode.Forbidden, new { Strings.Unauthorized }); + } + + Certificate certificate; + + try + { + using (var uploadStream = uploadFile.InputStream) + { + certificate = await CertificateService.AddCertificateAsync(uploadFile); + } + + await CertificateService.ActivateCertificateAsync(certificate.Thumbprint, account); + } + catch (UserSafeException ex) + { + ex.Log(); + + return Json(HttpStatusCode.BadRequest, new[] { ex.Message }); + } + + var activeCertificateCount = CertificateService.GetCertificates(account).Count(); + + if (activeCertificateCount == 1 && + SecurityPolicyService.IsSubscribed(account, AutomaticallyOverwriteRequiredSignerPolicy.PolicyName)) + { + await PackageService.SetRequiredSignerAsync(account); + } + + return Json(HttpStatusCode.Created, new { certificate.Thumbprint }); + } + + [HttpDelete] + [UIAuthorize] + [ValidateAntiForgeryToken] + [RequiresAccountConfirmation("delete a certificate")] + public virtual async Task DeleteCertificate(string accountName, string thumbprint) + { + if (string.IsNullOrEmpty(thumbprint)) + { + return Json(HttpStatusCode.BadRequest); + } + + var currentUser = GetCurrentUser(); + var account = GetAccount(accountName); + + if (currentUser == null) + { + return Json(HttpStatusCode.Unauthorized); + } + + if (account == null) + { + return Json(HttpStatusCode.NotFound); + } + + if (ActionsRequiringPermissions.ManageAccount.CheckPermissions(currentUser, account) + != PermissionsCheckResult.Allowed || !User.WasMultiFactorAuthenticated()) + { + return Json(HttpStatusCode.Forbidden, new { Strings.Unauthorized }); + } + + await CertificateService.DeactivateCertificateAsync(thumbprint, account); + + return Json(HttpStatusCode.OK); + } + + [HttpGet] + [UIAuthorize] + public virtual JsonResult GetCertificates(string accountName) + { + var currentUser = GetCurrentUser(); + var account = GetAccount(accountName); + + if (currentUser == null) + { + return Json(HttpStatusCode.Unauthorized); + } + + if (account == null) + { + return Json(HttpStatusCode.NotFound); + } + + if (ActionsRequiringPermissions.ViewAccount.CheckPermissions(currentUser, account) + != PermissionsCheckResult.Allowed) + { + return Json(HttpStatusCode.Forbidden); + } + + var wasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated(); + var canManage = ActionsRequiringPermissions.ManageAccount.CheckPermissions(currentUser, account) + == PermissionsCheckResult.Allowed; + var template = GetDeleteCertificateForAccountTemplate(accountName); + + var certificates = CertificateService.GetCertificates(account) + .Select(certificate => + { + string deactivateUrl = null; + + if (wasMultiFactorAuthenticated && canManage) + { + deactivateUrl = template.Resolve(certificate.Thumbprint); + } + + return new ListCertificateItemViewModel(certificate, deactivateUrl); + }); + + return Json(HttpStatusCode.OK, certificates, JsonRequestBehavior.AllowGet); + } + + [HttpGet] + [UIAuthorize] + public virtual JsonResult GetCertificate(string accountName, string thumbprint) + { + if (string.IsNullOrEmpty(thumbprint)) + { + return Json(HttpStatusCode.BadRequest); + } + + var currentUser = GetCurrentUser(); + var account = GetAccount(accountName); + + if (currentUser == null) + { + return Json(HttpStatusCode.Unauthorized); + } + + if (account == null) + { + return Json(HttpStatusCode.NotFound); + } + + if (ActionsRequiringPermissions.ViewAccount.CheckPermissions(currentUser, account) + != PermissionsCheckResult.Allowed) + { + return Json(HttpStatusCode.Forbidden); + } + + var wasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated(); + var canManage = ActionsRequiringPermissions.ManageAccount.CheckPermissions(currentUser, account) + == PermissionsCheckResult.Allowed; + var template = GetDeleteCertificateForAccountTemplate(accountName); + + var certificates = CertificateService.GetCertificates(account) + .Where(certificate => certificate.Thumbprint == thumbprint) + .Select(certificate => + { + string deactivateUrl = null; + + if (wasMultiFactorAuthenticated && canManage) + { + deactivateUrl = template.Resolve(certificate.Thumbprint); + } + + return new ListCertificateItemViewModel(certificate, deactivateUrl); + }); + + return Json(HttpStatusCode.OK, certificates, JsonRequestBehavior.AllowGet); + } + + protected abstract RouteUrlTemplate GetDeleteCertificateForAccountTemplate(string accountName); } } \ No newline at end of file diff --git a/src/NuGetGallery/Controllers/AppController.cs b/src/NuGetGallery/Controllers/AppController.cs index 8b11d03611..8d485ede18 100644 --- a/src/NuGetGallery/Controllers/AppController.cs +++ b/src/NuGetGallery/Controllers/AppController.cs @@ -66,6 +66,11 @@ protected internal JsonResult Json(HttpStatusCode statusCode, object obj, JsonRe return Json(obj, jsonRequestBehavior); } + protected internal JsonResult Json(HttpStatusCode statusCode) + { + return Json(statusCode, obj: new { }, jsonRequestBehavior: JsonRequestBehavior.DenyGet); + } + protected internal JsonResult Json(HttpStatusCode statusCode, object obj) { return Json(statusCode, obj, JsonRequestBehavior.DenyGet); diff --git a/src/NuGetGallery/Controllers/AuthenticationController.cs b/src/NuGetGallery/Controllers/AuthenticationController.cs index 5317db0bef..517e01fdd4 100644 --- a/src/NuGetGallery/Controllers/AuthenticationController.cs +++ b/src/NuGetGallery/Controllers/AuthenticationController.cs @@ -166,7 +166,7 @@ public virtual async Task SignIn(LogOnViewModel model, string retu } var authenticatedUser = authenticationResult.AuthenticatedUser; - + bool usedMultiFactorAuthentication = false; if (linkingAccount) { // Verify account has no other external accounts @@ -180,11 +180,14 @@ public virtual async Task SignIn(LogOnViewModel model, string retu } // Link with an external account - authenticatedUser = await AssociateCredential(authenticatedUser); + var loginUserDetails = await AssociateCredential(authenticatedUser); + authenticatedUser = loginUserDetails?.AuthenticatedUser; if (authenticatedUser == null) { return ExternalLinkExpired(); } + + usedMultiFactorAuthentication = loginUserDetails.UsedMultiFactorAuthentication; } // If we are an administrator and Gallery.EnforcedAuthProviderForAdmin is set @@ -197,7 +200,7 @@ public virtual async Task SignIn(LogOnViewModel model, string retu } // Create session - await _authService.CreateSessionAsync(OwinContext, authenticatedUser); + await _authService.CreateSessionAsync(OwinContext, authenticatedUser, usedMultiFactorAuthentication); TempData["ShowPasswordDeprecationWarning"] = true; return SafeRedirect(returnUrl); @@ -265,6 +268,7 @@ public virtual async Task Register(LogOnViewModel model, string re } AuthenticatedUser user; + var usedMultiFactorAuthentication = false; try { if (linkingAccount) @@ -275,6 +279,7 @@ public virtual async Task Register(LogOnViewModel model, string re return ExternalLinkExpired(); } + usedMultiFactorAuthentication = result.LoginDetails?.WasMultiFactorAuthenticated ?? false; user = await _authService.Register( model.Register.Username, model.Register.EmailAddress, @@ -316,7 +321,7 @@ public virtual async Task Register(LogOnViewModel model, string re } // Create session - await _authService.CreateSessionAsync(OwinContext, user); + await _authService.CreateSessionAsync(OwinContext, user, usedMultiFactorAuthentication); return RedirectFromRegister(returnUrl); } @@ -473,7 +478,8 @@ public virtual async Task LinkOrChangeExternalCredential(string re // Authenticate with the new credential after successful replacement var authenticatedUser = await _authService.Authenticate(newCredential); - await _authService.CreateSessionAsync(OwinContext, authenticatedUser); + var usedMultiFactorAuthentication = result.LoginDetails?.WasMultiFactorAuthenticated ?? false; + await _authService.CreateSessionAsync(OwinContext, authenticatedUser, usedMultiFactorAuthentication); // Get email address of the new credential for updating success message var newEmailAddress = GetEmailAddressFromExternalLoginResult(result, out string errorReason); @@ -523,13 +529,27 @@ public virtual async Task LinkExternalAccount(string returnUrl) { // Invoke the authentication again enforcing multi-factor authentication for the same provider. return ChallengeAuthentication( - Url.LinkExternalAccount(returnUrl), - result.Authenticator.Name, + Url.LinkExternalAccount(returnUrl), + result.Authenticator.Name, new AuthenticationPolicy() { Email = result.LoginDetails.EmailUsed, EnforceMultiFactorAuthentication = true }); } // Create session - await _authService.CreateSessionAsync(OwinContext, result.Authentication); + await _authService.CreateSessionAsync(OwinContext, + result.Authentication, + wasMultiFactorAuthenticated: result?.LoginDetails?.WasMultiFactorAuthenticated ?? false); + + // Update the 2FA if used during login but user does not have it set on their account. Only for personal microsoft accounts. + if (result?.LoginDetails != null + && result.LoginDetails.WasMultiFactorAuthenticated + && !result.Authentication.User.EnableMultiFactorAuthentication + && CredentialTypes.IsMicrosoftAccount(result.Credential.Type)) + { + await _userService.ChangeMultiFactorAuthentication(result.Authentication.User, enableMultiFactor: true); + OwinContext.AddClaim(NuGetClaims.EnabledMultiFactorAuthentication); + TempData["Message"] = Strings.MultiFactorAuth_LoginUpdate; + } + return SafeRedirect(returnUrl); } else @@ -648,7 +668,7 @@ private ActionResult RedirectFromRegister(string returnUrl) return RedirectToAction(actionName: "Thanks", controllerName: "Users"); } - private async Task AssociateCredential(AuthenticatedUser user) + private async Task AssociateCredential(AuthenticatedUser user) { var result = await _authService.ReadExternalLoginCredential(OwinContext); if (result.ExternalIdentity == null) @@ -669,7 +689,10 @@ private async Task AssociateCredential(AuthenticatedUser user // Notify the user of the change _messageService.SendCredentialAddedNotice(user.User, _authService.DescribeCredential(result.Credential)); - return new AuthenticatedUser(user.User, result.Credential); + return new LoginUserDetails { + AuthenticatedUser = new AuthenticatedUser(user.User, result.Credential), + UsedMultiFactorAuthentication = result.LoginDetails?.WasMultiFactorAuthenticated ?? false + }; } private List GetProviders() diff --git a/src/NuGetGallery/Controllers/OrganizationsController.cs b/src/NuGetGallery/Controllers/OrganizationsController.cs index 4214f511be..b73e464859 100644 --- a/src/NuGetGallery/Controllers/OrganizationsController.cs +++ b/src/NuGetGallery/Controllers/OrganizationsController.cs @@ -9,20 +9,37 @@ using System.Web.Mvc; using NuGetGallery.Authentication; using NuGetGallery.Filters; +using NuGetGallery.Helpers; +using NuGetGallery.Security; namespace NuGetGallery { public class OrganizationsController : AccountsController { + public IDeleteAccountService DeleteAccountService { get; } + public OrganizationsController( AuthenticationService authService, ICuratedFeedService curatedFeedService, IMessageService messageService, IUserService userService, - ITelemetryService telemetryService) - : base(authService, curatedFeedService, messageService, userService, telemetryService) + ITelemetryService telemetryService, + ISecurityPolicyService securityPolicyService, + ICertificateService certificateService, + IPackageService packageService, + IDeleteAccountService deleteAccountService) + : base( + authService, + curatedFeedService, + packageService, + messageService, + userService, + telemetryService, + securityPolicyService, + certificateService) { + DeleteAccountService = deleteAccountService; } public override string AccountAction => nameof(ManageOrganization); @@ -94,7 +111,7 @@ public async Task AddMember(string accountName, string memberName, b var account = GetAccount(accountName); if (account == null - || ActionsRequiringPermissions.ManageAccount.CheckPermissions(GetCurrentUser(), account) + || ActionsRequiringPermissions.ManageMembership.CheckPermissions(GetCurrentUser(), account) != PermissionsCheckResult.Allowed) { return Json(HttpStatusCode.Forbidden, Strings.Unauthorized); @@ -193,7 +210,7 @@ public async Task CancelMemberRequest(string accountName, string mem var account = GetAccount(accountName); if (account == null - || ActionsRequiringPermissions.ManageAccount.CheckPermissions(GetCurrentUser(), account) + || ActionsRequiringPermissions.ManageMembership.CheckPermissions(GetCurrentUser(), account) != PermissionsCheckResult.Allowed) { return Json(HttpStatusCode.Forbidden, Strings.Unauthorized); @@ -219,7 +236,7 @@ public async Task UpdateMember(string accountName, string memberName var account = GetAccount(accountName); if (account == null - || ActionsRequiringPermissions.ManageAccount.CheckPermissions(GetCurrentUser(), account) + || ActionsRequiringPermissions.ManageMembership.CheckPermissions(GetCurrentUser(), account) != PermissionsCheckResult.Allowed) { return Json(HttpStatusCode.Forbidden, Strings.Unauthorized); @@ -252,9 +269,9 @@ public async Task DeleteMember(string accountName, string memberName var currentUser = GetCurrentUser(); - if (account == null || - (currentUser.Username != memberName && - ActionsRequiringPermissions.ManageAccount.CheckPermissions(currentUser, account) + if (account == null || + (currentUser.Username != memberName && + ActionsRequiringPermissions.ManageMembership.CheckPermissions(currentUser, account) != PermissionsCheckResult.Allowed)) { return Json(HttpStatusCode.Forbidden, Strings.Unauthorized); @@ -277,6 +294,63 @@ public async Task DeleteMember(string accountName, string memberName } } + protected override DeleteAccountViewModel GetDeleteAccountViewModel(Organization account) + { + return GetDeleteOrganizationViewModel(account); + } + + private DeleteOrganizationViewModel GetDeleteOrganizationViewModel(Organization account) + { + return new DeleteOrganizationViewModel(account, GetCurrentUser(), PackageService); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [UIAuthorize] + public override async Task RequestAccountDeletion(string accountName = null) + { + var account = GetAccount(accountName); + var currentUser = GetCurrentUser(); + + if (account == null + || ActionsRequiringPermissions.ManageAccount.CheckPermissions(GetCurrentUser(), account) + != PermissionsCheckResult.Allowed) + { + return new HttpNotFoundResult(); + } + + var model = GetDeleteOrganizationViewModel(account); + + if (model.HasOrphanPackages) + { + TempData["ErrorMessage"] = "You cannot delete your organization unless you transfer ownership of all of its packages to another account."; + + return RedirectToAction(nameof(DeleteRequest)); + } + + if (model.HasAdditionalMembers) + { + TempData["ErrorMessage"] = "You cannot delete your organization unless you remove all other members."; + + return RedirectToAction(nameof(DeleteRequest)); + } + + var result = await DeleteAccountService.DeleteAccountAsync(account, currentUser, commitAsTransaction: true); + + if (result.Success) + { + TempData["Message"] = $"Your organization, '{accountName}', was successfully deleted!"; + + return RedirectToAction("Organizations", "Users"); + } + else + { + TempData["ErrorMessage"] = $"There was an issue deleting your organization '{accountName}'. Please contact support for assistance."; + + return RedirectToAction(nameof(DeleteRequest)); + } + } + protected override void UpdateAccountViewModel(Organization account, OrganizationAccountViewModel model) { base.UpdateAccountViewModel(account, model); @@ -286,6 +360,15 @@ protected override void UpdateAccountViewModel(Organization account, Organizatio .Concat(account.MemberRequests.Select(m => new OrganizationMemberViewModel(m))); model.RequiresTenant = account.IsRestrictedToOrganizationTenantPolicy(); + + model.CanManageMemberships = + ActionsRequiringPermissions.ManageMembership.CheckPermissions(GetCurrentUser(), account) + == PermissionsCheckResult.Allowed; + } + + protected override RouteUrlTemplate GetDeleteCertificateForAccountTemplate(string accountName) + { + return Url.DeleteOrganizationCertificateTemplate(accountName); } } } \ No newline at end of file diff --git a/src/NuGetGallery/Controllers/PackagesController.cs b/src/NuGetGallery/Controllers/PackagesController.cs index e1126ba80c..f78ae0b22c 100644 --- a/src/NuGetGallery/Controllers/PackagesController.cs +++ b/src/NuGetGallery/Controllers/PackagesController.cs @@ -1188,6 +1188,7 @@ public virtual async Task Edit(string id, string version) PackageId = package.PackageRegistration.Id, PackageTitle = package.Title, Version = package.NormalizedVersion, + PackageRegistration = package.PackageRegistration, IsLocked = package.PackageRegistration.IsLocked, }; @@ -1783,7 +1784,45 @@ internal virtual async Task SetLicenseReportVisibility(string id, return Redirect(urlFactory(package, /*relativeUrl:*/ true)); } - // this methods exist to make unit testing easier + [UIAuthorize] + [HttpPost] + [ValidateAntiForgeryToken] + public virtual async Task SetRequiredSigner(string id, string username) + { + var packageRegistration = _packageService.FindPackageRegistrationById(id); + + if (packageRegistration == null) + { + return Json(HttpStatusCode.NotFound); + } + + var currentUser = GetCurrentUser(); + + if (ActionsRequiringPermissions.ManagePackageRequiredSigner + .CheckPermissionsOnBehalfOfAnyAccount(currentUser, packageRegistration) + != PermissionsCheckResult.Allowed || !User.WasMultiFactorAuthenticated()) + { + return Json(HttpStatusCode.Forbidden); + } + + User signer = null; + + if (!string.IsNullOrEmpty(username)) + { + signer = _userService.FindByUsername(username); + + if (signer == null) + { + return Json(HttpStatusCode.NotFound); + } + } + + await _packageService.SetRequiredSignerAsync(packageRegistration, signer); + + return Json(HttpStatusCode.OK); + } + + // this method exists to make unit testing easier protected internal virtual PackageArchiveReader CreatePackage(Stream stream) { try diff --git a/src/NuGetGallery/Controllers/UsersController.cs b/src/NuGetGallery/Controllers/UsersController.cs index db7deb1107..3d3d055184 100644 --- a/src/NuGetGallery/Controllers/UsersController.cs +++ b/src/NuGetGallery/Controllers/UsersController.cs @@ -9,25 +9,25 @@ using System.Threading.Tasks; using System.Web.Mvc; using NuGetGallery.Areas.Admin; -using NuGetGallery.Areas.Admin.Models; using NuGetGallery.Areas.Admin.ViewModels; using NuGetGallery.Authentication; using NuGetGallery.Configuration; using NuGetGallery.Filters; +using NuGetGallery.Helpers; using NuGetGallery.Infrastructure.Authentication; +using NuGetGallery.Security; namespace NuGetGallery { public partial class UsersController : AccountsController { - private readonly IPackageService _packageService; private readonly IPackageOwnerRequestService _packageOwnerRequestService; private readonly IAppConfiguration _config; private readonly ICredentialBuilder _credentialBuilder; private readonly IDeleteAccountService _deleteAccountService; private readonly ISupportRequestService _supportRequestService; - + public UsersController( ICuratedFeedService feedsQuery, IUserService userService, @@ -39,17 +39,26 @@ public UsersController( ICredentialBuilder credentialBuilder, IDeleteAccountService deleteAccountService, ISupportRequestService supportRequestService, - ITelemetryService telemetryService) - : base(authService, feedsQuery, messageService, userService, telemetryService) + ITelemetryService telemetryService, + ISecurityPolicyService securityPolicyService, + ICertificateService certificateService) + : base( + authService, + feedsQuery, + packageService, + messageService, + userService, + telemetryService, + securityPolicyService, + certificateService) { - _packageService = packageService ?? throw new ArgumentNullException(nameof(packageService)); _packageOwnerRequestService = packageOwnerRequestService ?? throw new ArgumentNullException(nameof(packageOwnerRequestService)); _config = config ?? throw new ArgumentNullException(nameof(config)); _credentialBuilder = credentialBuilder ?? throw new ArgumentNullException(nameof(credentialBuilder)); _deleteAccountService = deleteAccountService ?? throw new ArgumentNullException(nameof(deleteAccountService)); _supportRequestService = supportRequestService ?? throw new ArgumentNullException(nameof(supportRequestService)); } - + public override string AccountAction => nameof(Account); protected internal override ViewMessages Messages => new ViewMessages @@ -83,6 +92,11 @@ protected override User GetAccount(string accountName) return null; } + protected override DeleteAccountViewModel GetDeleteAccountViewModel(User account) + { + return new DeleteUserViewModel(account, GetCurrentUser(), PackageService, _supportRequestService); + } + [HttpGet] [UIAuthorize] public virtual ActionResult Account() @@ -96,7 +110,7 @@ public virtual ActionResult Account() public virtual ActionResult TransformToOrganization() { var accountToTransform = GetCurrentUser(); - + string errorReason; if (!UserService.CanTransformUserToOrganization(accountToTransform, out errorReason)) { @@ -162,7 +176,7 @@ public virtual async Task TransformToOrganization(TransformAccount Strings.TransformAccount_SignInToConfirm, adminUser.Username, accountToTransform.Username); return Redirect(Url.LogOn(returnUrl)); } - + [HttpGet] [UIAuthorize(allowDiscontinuedLogins: true)] [ActionName(RouteName.TransformToOrganizationConfirmation)] @@ -243,7 +257,7 @@ public virtual async Task CancelTransformToOrganization(string tok { var accountToTransform = GetCurrentUser(); var adminUser = accountToTransform.OrganizationMigrationRequest?.AdminUser; - + if (await UserService.CancelTransformUserToOrganizationRequest(accountToTransform, token)) { MessageService.SendOrganizationTransformRequestCancelledNotice(accountToTransform, adminUser); @@ -266,89 +280,66 @@ private ActionResult TransformToOrganizationFailed(string errorMessage) return View("TransformToOrganizationFailed", new TransformAccountFailedViewModel(errorMessage)); } - [HttpGet] - [UIAuthorize] - public virtual ActionResult DeleteRequest() - { - var currentUser = GetCurrentUser(); - - if (currentUser == null || currentUser.IsDeleted) - { - return HttpNotFound("User not found."); - } - - var listPackageItems = _packageService - .FindPackagesByAnyMatchingOwner(currentUser, includeUnlisted: true) - .Select(p => new ListPackageItemViewModel(p, currentUser)) - .ToList(); - - bool hasPendingRequest = _supportRequestService.GetIssues().Where((issue) => (issue.UserKey.HasValue && issue.UserKey.Value == currentUser.Key) && - string.Equals(issue.IssueTitle, Strings.AccountDelete_SupportRequestTitle) && - issue.Key != IssueStatusKeys.Resolved).Any(); - - var model = new DeleteAccountViewModel() - { - Packages = listPackageItems, - User = currentUser, - AccountName = currentUser.Username, - HasPendingRequests = hasPendingRequest - }; - - return View("DeleteAccount", model); - } - [HttpPost] [UIAuthorize] [ValidateAntiForgeryToken] - public virtual async Task RequestAccountDeletion() + public override async Task RequestAccountDeletion(string accountName = null) { - var user = GetCurrentUser(); + var user = GetAccount(accountName); if (user == null || user.IsDeleted) { - return HttpNotFound("User not found."); + return HttpNotFound(); + } + + if (!user.Confirmed) + { + // Unconfirmed users can be deleted immediately without creating a support request. + DeleteUserAccountStatus accountDeleteStatus = await _deleteAccountService.DeleteAccountAsync(userToBeDeleted: user, + userToExecuteTheDelete: user, + signature: user.Username, + orphanPackagePolicy: AccountDeletionOrphanPackagePolicy.UnlistOrphans, + commitAsTransaction: true); + if (!accountDeleteStatus.Success) + { + TempData["RequestFailedMessage"] = Strings.AccountSelfDelete_Fail; + return RedirectToAction("DeleteRequest"); + } + OwinContext.Authentication.SignOut(); + return SafeRedirect(Url.Home(false)); } var isSupportRequestCreated = await _supportRequestService.TryAddDeleteSupportRequestAsync(user); - if (!isSupportRequestCreated) + if (await _supportRequestService.TryAddDeleteSupportRequestAsync(user)) + { + MessageService.SendAccountDeleteNotice(user); + } + else { TempData["RequestFailedMessage"] = Strings.AccountDelete_CreateSupportRequestFails; - return RedirectToAction("DeleteRequest"); } - MessageService.SendAccountDeleteNotice(user.ToMailAddress(), user.Username); - return RedirectToAction("DeleteRequest"); + return RedirectToAction(nameof(DeleteRequest)); } [HttpGet] [UIAuthorize(Roles = "Admins")] public virtual ActionResult Delete(string accountName) { - var currentUser = GetCurrentUser(); var user = UserService.FindByUsername(accountName); if (user == null || user.IsDeleted || (user is Organization)) { - return HttpNotFound("User not found."); + return HttpNotFound(); } - - var listPackageItems = _packageService - .FindPackagesByAnyMatchingOwner(user, includeUnlisted: true) - .Select(p => new ListPackageItemViewModel(p, currentUser)) - .ToList(); - var model = new DeleteUserAccountViewModel - { - Packages = listPackageItems, - User = user, - AccountName = user.Username, - }; - return View("DeleteUserAccount", model); + + return View("DeleteUserAccount", GetDeleteAccountViewModel(user)); } [HttpDelete] [UIAuthorize(Roles = "Admins")] [RequiresAccountConfirmation("Delete account")] [ValidateAntiForgeryToken] - public virtual async Task Delete(DeleteUserAccountViewModel model) + public virtual async Task Delete(DeleteAccountAsAdminViewModel model) { var user = UserService.FindByUsername(model.AccountName); if (user == null || user.IsDeleted) @@ -363,7 +354,12 @@ public virtual async Task Delete(DeleteUserAccountViewModel model) else { var admin = GetCurrentUser(); - var status = await _deleteAccountService.DeleteGalleryUserAccountAsync(user, admin, model.Signature, model.ShouldUnlist, commitAsTransaction: true); + var status = await _deleteAccountService.DeleteAccountAsync( + userToBeDeleted: user, + userToExecuteTheDelete: admin, + signature: model.Signature, + orphanPackagePolicy: model.ShouldUnlist ? AccountDeletionOrphanPackagePolicy.UnlistOrphans : AccountDeletionOrphanPackagePolicy.KeepOrphans, + commitAsTransaction: true); return View("DeleteUserAccountStatus", status); } } @@ -410,12 +406,12 @@ private ApiKeyOwnerViewModel CreateApiKeyOwnerViewModel(User currentUser, User a ActionsRequiringPermissions.UploadNewPackageId.IsAllowedOnBehalfOfAccount(currentUser, account), ActionsRequiringPermissions.UploadNewPackageVersion.IsAllowedOnBehalfOfAccount(currentUser, account), ActionsRequiringPermissions.UnlistOrRelistPackage.IsAllowedOnBehalfOfAccount(currentUser, account), - packageIds: _packageService.FindPackageRegistrationsByOwner(account) + packageIds: PackageService.FindPackageRegistrationsByOwner(account) .Select(p => p.Id) .OrderBy(i => i) .ToList()); } - + [HttpGet] [UIAuthorize(allowDiscontinuedLogins: true)] public virtual ActionResult Thanks() @@ -440,14 +436,18 @@ public virtual ActionResult Packages() new ListPackageOwnerViewModel(currentUser) }.Concat(currentUser.Organizations.Select(o => new ListPackageOwnerViewModel(o.Organization))); - var packages = _packageService.FindPackagesByAnyMatchingOwner(currentUser, includeUnlisted: true); + var wasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated(); + + var packages = PackageService.FindPackagesByAnyMatchingOwner(currentUser, includeUnlisted: true); var listedPackages = packages .Where(p => p.Listed) - .Select(p => new ListPackageItemViewModel(p, currentUser)).OrderBy(p => p.Id) + .Select(p => new ListPackageItemRequiredSignerViewModel(p, currentUser, SecurityPolicyService, wasMultiFactorAuthenticated)) + .OrderBy(p => p.Id) .ToList(); var unlistedPackages = packages .Where(p => !p.Listed) - .Select(p => new ListPackageItemViewModel(p, currentUser)).OrderBy(p => p.Id) + .Select(p => new ListPackageItemRequiredSignerViewModel(p, currentUser, SecurityPolicyService, wasMultiFactorAuthenticated)) + .OrderBy(p => p.Id) .ToList(); // find all received ownership requests @@ -464,7 +464,7 @@ public virtual ActionResult Packages() .SelectMany(m => _packageOwnerRequestService.GetPackageOwnershipRequests(requestingOwner: m.Organization)); var sent = userSent.Union(orgSent); - var ownerRequests = new OwnerRequestsViewModel(received, sent, currentUser, _packageService); + var ownerRequests = new OwnerRequestsViewModel(received, sent, currentUser, PackageService); var userReservedNamespaces = currentUser.ReservedNamespaces; var organizationsReservedNamespaces = currentUser.Organizations.SelectMany(m => m.Organization.ReservedNamespaces); @@ -473,11 +473,13 @@ public virtual ActionResult Packages() var model = new ManagePackagesViewModel { + User = currentUser, Owners = owners, ListedPackages = listedPackages, UnlistedPackages = unlistedPackages, OwnerRequests = ownerRequests, - ReservedNamespaces = reservedPrefixes + ReservedNamespaces = reservedPrefixes, + WasMultiFactorAuthenticated = User.WasMultiFactorAuthenticated() }; return View(model); } @@ -488,7 +490,7 @@ public virtual ActionResult Organizations() { var currentUser = GetCurrentUser(); - var model = new ManageOrganizationsViewModel(currentUser, _packageService); + var model = new ManageOrganizationsViewModel(currentUser, PackageService); return View(model); } @@ -598,7 +600,7 @@ public virtual async Task ResetPassword(string username, string to return RedirectToAction("PasswordChanged"); } - + [HttpGet] public virtual ActionResult Profiles(string username, int page = 1) { @@ -609,7 +611,7 @@ public virtual ActionResult Profiles(string username, int page = 1) return HttpNotFound(); } - var packages = _packageService.FindPackagesByOwner(user, includeUnlisted: false) + var packages = PackageService.FindPackagesByOwner(user, includeUnlisted: false) .OrderByDescending(p => p.PackageRegistration.DownloadCount) .Select(p => new ListPackageItemViewModel(p, currentUser) { @@ -645,7 +647,7 @@ public virtual async Task ChangePassword(UserAccountViewModel mode } else { - if (!model.ChangePassword.EnablePasswordLogin) + if (model.ChangePassword.DisablePasswordLogin) { return await RemovePassword(); } @@ -672,6 +674,33 @@ public virtual async Task ChangePassword(UserAccountViewModel mode } } + [HttpPost] + [UIAuthorize] + [ValidateAntiForgeryToken] + public virtual async Task ChangeMultiFactorAuthentication(bool enableMultiFactor) + { + var user = GetCurrentUser(); + + await UserService.ChangeMultiFactorAuthentication(user, enableMultiFactor); + + TempData["Message"] = string.Format( + enableMultiFactor ? Strings.MultiFactorAuth_Enabled : Strings.MultiFactorAuth_Disabled, + _config.Brand); + + if (enableMultiFactor) + { + // Add the claim to remove the warning indicators for 2FA. + OwinContext.AddClaim(NuGetClaims.EnabledMultiFactorAuthentication); + } + else + { + // Remove the claim from login to show warning indicators for 2FA. + OwinContext.RemoveClaim(NuGetClaims.EnabledMultiFactorAuthentication); + } + + return RedirectToAction(AccountAction); + } + [HttpPost] [UIAuthorize] [ValidateAntiForgeryToken] @@ -777,7 +806,7 @@ public virtual async Task GenerateApiKey(string description, string Response.StatusCode = (int)HttpStatusCode.BadRequest; return Json(Strings.UserNotFound); } - + var resolvedScopes = BuildScopes(scopeOwner, scopes, subjects); if (!VerifyScopes(resolvedScopes)) { @@ -837,6 +866,11 @@ public virtual async Task EditCredential(string credentialType, int? return Json(new ApiKeyViewModel(credentialViewModel)); } + protected override RouteUrlTemplate GetDeleteCertificateForAccountTemplate(string accountName) + { + return Url.DeleteUserCertificateTemplate(); + } + private async Task GenerateApiKeyInternal(string description, ICollection scopes, TimeSpan? expiration) { var user = GetCurrentUser(); @@ -861,7 +895,7 @@ private async Task GenerateApiKeyInternal(string descriptio { NuGetScopes.PackageUnlist, new [] { ActionsRequiringPermissions.UnlistOrRelistPackage } }, { NuGetScopes.PackageVerify, new [] { ActionsRequiringPermissions.VerifyPackage } }, }; - + private bool VerifyScopes(IEnumerable scopes) { if (!scopes.Any()) @@ -877,7 +911,7 @@ private bool VerifyScopes(IEnumerable scopes) // All scopes must have an allowed action. return false; } - + // Get the list of actions allowed by this scope. var actions = new List(); foreach (var allowedAction in AllowedActionToActionRequiringEntityPermissionsMap.Keys) @@ -973,6 +1007,12 @@ private async Task RemoveCredentialInternal(User user, Credential { await AuthenticationService.RemoveCredential(user, cred); + if (cred.IsPassword()) + { + // Clear the password login claim, to remove warnings. + OwinContext.RemoveClaim(NuGetClaims.PasswordLogin); + } + // Notify the user of the change MessageService.SendCredentialRemovedNotice(user, AuthenticationService.DescribeCredential(cred)); @@ -993,7 +1033,7 @@ protected override void UpdateAccountViewModel(User account, UserAccountViewMode .Sum(p => p.Value.Count); model.ChangePassword = model.ChangePassword ?? new ChangePasswordViewModel(); - model.ChangePassword.EnablePasswordLogin = model.HasPassword; + model.ChangePassword.DisablePasswordLogin = !model.HasPassword; } private Dictionary> GetCredentialGroups(User user) diff --git a/src/NuGetGallery/ExtensionMethods.cs b/src/NuGetGallery/ExtensionMethods.cs index 9e1a3fecb4..d0f6948b30 100644 --- a/src/NuGetGallery/ExtensionMethods.cs +++ b/src/NuGetGallery/ExtensionMethods.cs @@ -9,12 +9,14 @@ using System.Linq.Expressions; using System.Security; using System.Security.Claims; +using System.Security.Principal; using System.Text; using System.Web; using System.Web.Mvc; using System.Web.Mvc.Html; using System.Web.WebPages; using Microsoft.Owin; +using Microsoft.Owin.Security; using NuGet.Frameworks; using NuGet.Packaging; using NuGetGallery.Helpers; @@ -481,6 +483,57 @@ public static User GetCurrentUser(this IOwinContext self) return user; } + /// + /// This method will add the claim to the OwinContext with default value and update the cookie with the updated claims + /// + /// True if successfully adds the claim to the context, false otherwise + public static bool AddClaim(this IOwinContext self, string claimType) + { + var identity = GetIdentity(self); + if (identity == null || !identity.IsAuthenticated) + { + return false; + } + + if (identity.TryAddClaim(claimType)) + { + // Update the cookies for the newly added claim + self.Authentication.AuthenticationResponseGrant = new AuthenticationResponseGrant(new ClaimsPrincipal(identity), new AuthenticationProperties() { IsPersistent = true }); + return true; + } + + return false; + } + + /// + /// This method will remove the claim from the OwinContext and update the cookie with the updated claims + /// + /// True if successfully removed the claim from context, false otherwise + public static bool RemoveClaim(this IOwinContext self, string claimType) + { + var identity = GetIdentity(self); + if (identity == null || !identity.IsAuthenticated) + { + return false; + } + + if (identity.TryRemoveClaim(claimType)) + { + // Update the cookies for the removed claim + self.Authentication.AuthenticationResponseGrant = new AuthenticationResponseGrant(new ClaimsPrincipal(identity), new AuthenticationProperties() { IsPersistent = true }); + return true; + } + + return false; + } + + private static IIdentity GetIdentity(IOwinContext context) + { + var responseGrantIdentity = context.Authentication?.AuthenticationResponseGrant?.Identity; + var authenticatedUserIdentity = context.Authentication?.User?.Identity; + return responseGrantIdentity ?? authenticatedUserIdentity; + } + private static User LoadUser(IOwinContext context) { var principal = context.Authentication.User; diff --git a/src/NuGetGallery/Extensions/ClaimsExtensions.cs b/src/NuGetGallery/Extensions/ClaimsExtensions.cs index f3d50d6fbe..4ddc3f095e 100644 --- a/src/NuGetGallery/Extensions/ClaimsExtensions.cs +++ b/src/NuGetGallery/Extensions/ClaimsExtensions.cs @@ -58,5 +58,44 @@ public static bool HasBooleanClaim(ClaimsIdentity identity, string claimType) .Equals(BooleanClaimDefault, StringComparison.OrdinalIgnoreCase) ?? false; } + + public static Claim CreateBooleanClaim(string claimType) + { + return new Claim(claimType, BooleanClaimDefault); + } + + public static void AddExternalLoginCredentialTypeClaim(List claims, string credentialType) + { + string claimValue = null; + if (CredentialTypes.IsMicrosoftAccount(credentialType)) + { + claimValue = NuGetClaims.ExternalLoginCredentialValues.MicrosoftAccount; + } + else if (CredentialTypes.IsAzureActiveDirectoryAccount(credentialType)) + { + claimValue = NuGetClaims.ExternalLoginCredentialValues.AzureActiveDirectory; + } + + if (!string.IsNullOrEmpty(claimValue)) + { + claims.Add(new Claim(NuGetClaims.ExternalLoginCredentialType, claimValue)); + } + } + + public static bool LoggedInWithMicrosoftAccount(ClaimsIdentity identity) + { + return identity + .GetClaimOrDefault(NuGetClaims.ExternalLoginCredentialType)? + .Equals(NuGetClaims.ExternalLoginCredentialValues.MicrosoftAccount, StringComparison.OrdinalIgnoreCase) + ?? false; + } + + public static bool LoggedInWithAzureActiveDirectory(ClaimsIdentity identity) + { + return identity + .GetClaimOrDefault(NuGetClaims.ExternalLoginCredentialType)? + .Equals(NuGetClaims.ExternalLoginCredentialValues.AzureActiveDirectory, StringComparison.OrdinalIgnoreCase) + ?? false; + } } } diff --git a/src/NuGetGallery/Extensions/OrganizationExtensions.cs b/src/NuGetGallery/Extensions/OrganizationExtensions.cs index 58cea85e08..7627aaea80 100644 --- a/src/NuGetGallery/Extensions/OrganizationExtensions.cs +++ b/src/NuGetGallery/Extensions/OrganizationExtensions.cs @@ -10,7 +10,7 @@ public static class OrganizationExtensions { public static Membership GetMembershipOfUser(this Organization organization, User member) { - return organization.Members.FirstOrDefault(m => m.Member.MatchesUser(member)); + return organization?.Members?.FirstOrDefault(m => m.Member.MatchesUser(member)); } /// diff --git a/src/NuGetGallery/Extensions/PrincipalExtensions.cs b/src/NuGetGallery/Extensions/PrincipalExtensions.cs index f39ceae760..39c78ea239 100644 --- a/src/NuGetGallery/Extensions/PrincipalExtensions.cs +++ b/src/NuGetGallery/Extensions/PrincipalExtensions.cs @@ -87,6 +87,35 @@ public static bool IsAdministrator(this IPrincipal self) /// Current user principal. /// True if user has password credential, false otherwise. public static bool HasPasswordLogin(this IPrincipal self) + { + return HasBooleanClaim(self, NuGetClaims.PasswordLogin); + } + + /// + /// Determine if the current user has multi-factor authentication enabled. + /// + /// Current user principal. + /// True if user has multi-factor authentication enabled, false otherwise. + public static bool HasMultiFactorAuthenticationEnabled(this IPrincipal self) + { + return HasBooleanClaim(self, NuGetClaims.EnabledMultiFactorAuthentication); + } + + /// + /// Determine if the current user was multi-factor authenticated + /// + /// Current user principal. + /// True if user was multi-factor authenticated, false otherwise. + public static bool WasMultiFactorAuthenticated(this IPrincipal self) + { + return HasBooleanClaim(self, NuGetClaims.WasMultiFactorAuthenticated); + } + + /// + /// Determine if the current user logged in with personal microsoft account + /// + /// Current user principal. + public static bool WasMicrosoftAccountUsedForSignin(this IPrincipal self) { if (self == null || self.Identity == null) { @@ -94,15 +123,14 @@ public static bool HasPasswordLogin(this IPrincipal self) } var identity = self.Identity as ClaimsIdentity; - return ClaimsExtensions.HasBooleanClaim(identity, NuGetClaims.PasswordLogin); + return ClaimsExtensions.LoggedInWithMicrosoftAccount(identity); } /// - /// Determine if the current user has an associated external credential. + /// Determine if the current user logged in with azure active directory account /// /// Current user principal. - /// True if user has password credential, false otherwise. - public static bool HasExternalLogin(this IPrincipal self) + public static bool WasAzureActiveDirectoryAccountUsedForSignin(this IPrincipal self) { if (self == null || self.Identity == null) { @@ -110,7 +138,17 @@ public static bool HasExternalLogin(this IPrincipal self) } var identity = self.Identity as ClaimsIdentity; - return ClaimsExtensions.HasBooleanClaim(identity, NuGetClaims.ExternalLogin); + return ClaimsExtensions.LoggedInWithAzureActiveDirectory(identity); + } + + /// + /// Determine if the current user has an associated external credential. + /// + /// Current user principal. + /// True if user has password credential, false otherwise. + public static bool HasExternalLogin(this IPrincipal self) + { + return HasBooleanClaim(self, NuGetClaims.ExternalLogin); } /// @@ -156,7 +194,44 @@ public static bool HasScopeThatAllowsActions(this IIdentity self, params string[ return !self.IsScopedAuthentication() || self.HasExplicitScopeAction(requestedActions); } - + + /// + /// Try to remove the claim from the identity + /// + /// IIdentity from which the claim is to be removed + /// The claim type to be removed + /// True if successfully able to remove the claim, false otherwise + public static bool TryRemoveClaim(this IIdentity identity, string claimType) + { + var claimsIdentity = identity as ClaimsIdentity; + var claim = claimsIdentity?.FindFirst(claimType); + if (claim != null) + { + return claimsIdentity.TryRemoveClaim(claim); + } + + return false; + } + + /// + /// Try to add a new default claim to the identity. It will not replace an existing claim. + /// + /// IIdentity from which the claim is to be removed + /// The claim type to be added + /// True if successfully able to add the claim, false otherwise + public static bool TryAddClaim(this IIdentity identity, string claimType) + { + var claimsIdentity = identity as ClaimsIdentity; + var existingClaim = claimsIdentity?.FindFirst(claimType); + if (existingClaim == null) + { + claimsIdentity.AddClaim(ClaimsExtensions.CreateBooleanClaim(claimType)); + return true; + } + + return false; + } + private static string GetScopeClaim(IIdentity self) { if (self == null) @@ -172,5 +247,16 @@ private static bool IsEmptyScopeClaim(string scopeClaim) { return string.IsNullOrEmpty(scopeClaim) || scopeClaim == "[]"; } + + private static bool HasBooleanClaim(IPrincipal self, string claimType) + { + if (self == null || self.Identity == null) + { + return false; + } + + var identity = self.Identity as ClaimsIdentity; + return ClaimsExtensions.HasBooleanClaim(identity, claimType); + } } } \ No newline at end of file diff --git a/src/NuGetGallery/Extensions/RouteExtensions.cs b/src/NuGetGallery/Extensions/RouteExtensions.cs index c2e3239b8a..9c5c0fdf23 100644 --- a/src/NuGetGallery/Extensions/RouteExtensions.cs +++ b/src/NuGetGallery/Extensions/RouteExtensions.cs @@ -27,6 +27,12 @@ public ObfuscatedMetadata(int obfuscatedSegment, string obfuscateValue) internal static Dictionary ObfuscatedRouteMap = new Dictionary(); + public static void MapRoute(this RouteCollection routes, string name, string url, object defaults, object constraints, ObfuscatedMetadata obfuscationMetadata) + { + routes.MapRoute(name, url, defaults, constraints); + if (!ObfuscatedRouteMap.ContainsKey(url)) { ObfuscatedRouteMap.Add(url, new[] { obfuscationMetadata }); } + } + public static void MapRoute(this RouteCollection routes, string name, string url, object defaults, ObfuscatedMetadata obfuscationMetadata) { routes.MapRoute(name, url, defaults, new[] { obfuscationMetadata }); diff --git a/src/NuGetGallery/Helpers/ObfuscationHelper.cs b/src/NuGetGallery/Helpers/ObfuscationHelper.cs new file mode 100644 index 0000000000..c3dad1bce6 --- /dev/null +++ b/src/NuGetGallery/Helpers/ObfuscationHelper.cs @@ -0,0 +1,23 @@ +// 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.Web; +using System.Web.Routing; + +namespace NuGetGallery.Helpers +{ + public class ObfuscationHelper + { + public static string ObfuscateRequestUrl(HttpContextBase httpContext, RouteCollection routes) + { + if (httpContext?.Request?.Url == null || routes == null) + { + return string.Empty; + } + + var route = routes.GetRouteData(httpContext)?.Route as Route; + return route == null ? string.Empty : route.ObfuscateUrlPath(httpContext.Request.Url.AbsolutePath.TrimStart('/')); + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.Designer.cs b/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.Designer.cs new file mode 100644 index 0000000000..b090a05528 --- /dev/null +++ b/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.Designer.cs @@ -0,0 +1,29 @@ +// +namespace NuGetGallery.Migrations +{ + using System.CodeDom.Compiler; + using System.Data.Entity.Migrations; + using System.Data.Entity.Migrations.Infrastructure; + using System.Resources; + + [GeneratedCode("EntityFramework.Migrations", "6.1.3-40302")] + public sealed partial class AddCertificateRegistration : IMigrationMetadata + { + private readonly ResourceManager Resources = new ResourceManager(typeof(AddCertificateRegistration)); + + string IMigrationMetadata.Id + { + get { return "201804171613337_AddCertificateRegistration"; } + } + + string IMigrationMetadata.Source + { + get { return null; } + } + + string IMigrationMetadata.Target + { + get { return Resources.GetString("Target"); } + } + } +} diff --git a/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.cs b/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.cs new file mode 100644 index 0000000000..fab4efcf63 --- /dev/null +++ b/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.cs @@ -0,0 +1,62 @@ +namespace NuGetGallery.Migrations +{ + using System; + using System.Data.Entity.Migrations; + + public partial class AddCertificateRegistration : DbMigration + { + public override void Up() + { + CreateTable( + "dbo.UserCertificates", + c => new + { + Key = c.Int(nullable: false, identity: true), + CertificateKey = c.Int(nullable: false), + UserKey = c.Int(nullable: false), + IsActive = c.Boolean(nullable: false), + }) + .PrimaryKey(t => t.Key) + .ForeignKey("dbo.Users", t => t.UserKey, cascadeDelete: true) + .ForeignKey("dbo.Certificates", t => t.CertificateKey, cascadeDelete: true) + .Index(t => t.CertificateKey) + .Index(t => t.UserKey); + + CreateTable( + "dbo.PackageRegistrationRequiredSigners", + c => new + { + PackageRegistrationKey = c.Int(nullable: false), + UserKey = c.Int(nullable: false), + }) + .PrimaryKey(t => new { t.PackageRegistrationKey, t.UserKey }) + .ForeignKey("dbo.PackageRegistrations", t => t.PackageRegistrationKey, cascadeDelete: true) + .ForeignKey("dbo.Users", t => t.UserKey, cascadeDelete: true) + .Index(t => t.PackageRegistrationKey) + .Index(t => t.UserKey); + + AddColumn("dbo.Certificates", "Sha1Thumbprint", c => c.String(maxLength: 40, unicode: false)); + AddColumn("dbo.Packages", "CertificateKey", c => c.Int()); + CreateIndex("dbo.Packages", "CertificateKey"); + AddForeignKey("dbo.Packages", "CertificateKey", "dbo.Certificates", "Key"); + } + + public override void Down() + { + DropForeignKey("dbo.UserCertificates", "CertificateKey", "dbo.Certificates"); + DropForeignKey("dbo.UserCertificates", "UserKey", "dbo.Users"); + DropForeignKey("dbo.PackageRegistrationRequiredSigners", "UserKey", "dbo.Users"); + DropForeignKey("dbo.PackageRegistrationRequiredSigners", "PackageRegistrationKey", "dbo.PackageRegistrations"); + DropForeignKey("dbo.Packages", "CertificateKey", "dbo.Certificates"); + DropIndex("dbo.PackageRegistrationRequiredSigners", new[] { "UserKey" }); + DropIndex("dbo.PackageRegistrationRequiredSigners", new[] { "PackageRegistrationKey" }); + DropIndex("dbo.Packages", new[] { "CertificateKey" }); + DropIndex("dbo.UserCertificates", new[] { "UserKey" }); + DropIndex("dbo.UserCertificates", new[] { "CertificateKey" }); + DropColumn("dbo.Packages", "CertificateKey"); + DropColumn("dbo.Certificates", "Sha1Thumbprint"); + DropTable("dbo.PackageRegistrationRequiredSigners"); + DropTable("dbo.UserCertificates"); + } + } +} diff --git a/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.resx b/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.resx new file mode 100644 index 0000000000..61a73ceb94 --- /dev/null +++ b/src/NuGetGallery/Migrations/201804171613337_AddCertificateRegistration.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 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 + + + dbo + + \ No newline at end of file diff --git a/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.Designer.cs b/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.Designer.cs new file mode 100644 index 0000000000..2261454027 --- /dev/null +++ b/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.Designer.cs @@ -0,0 +1,29 @@ +// +namespace NuGetGallery.Migrations +{ + using System.CodeDom.Compiler; + using System.Data.Entity.Migrations; + using System.Data.Entity.Migrations.Infrastructure; + using System.Resources; + + [GeneratedCode("EntityFramework.Migrations", "6.1.3-40302")] + public sealed partial class RemoveUserCertificateIsActive : IMigrationMetadata + { + private readonly ResourceManager Resources = new ResourceManager(typeof(RemoveUserCertificateIsActive)); + + string IMigrationMetadata.Id + { + get { return "201804241507196_RemoveUserCertificateIsActive"; } + } + + string IMigrationMetadata.Source + { + get { return null; } + } + + string IMigrationMetadata.Target + { + get { return Resources.GetString("Target"); } + } + } +} diff --git a/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.cs b/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.cs new file mode 100644 index 0000000000..01bbfc895e --- /dev/null +++ b/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.cs @@ -0,0 +1,18 @@ +namespace NuGetGallery.Migrations +{ + using System; + using System.Data.Entity.Migrations; + + public partial class RemoveUserCertificateIsActive : DbMigration + { + public override void Up() + { + DropColumn("dbo.UserCertificates", "IsActive"); + } + + public override void Down() + { + AddColumn("dbo.UserCertificates", "IsActive", c => c.Boolean(nullable: false)); + } + } +} diff --git a/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.resx b/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.resx new file mode 100644 index 0000000000..ea863a5eaf --- /dev/null +++ b/src/NuGetGallery/Migrations/201804241507196_RemoveUserCertificateIsActive.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 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 + + + dbo + + \ No newline at end of file diff --git a/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.Designer.cs b/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.Designer.cs new file mode 100644 index 0000000000..93f40f0802 --- /dev/null +++ b/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.Designer.cs @@ -0,0 +1,29 @@ +// +namespace NuGetGallery.Migrations +{ + using System.CodeDom.Compiler; + using System.Data.Entity.Migrations; + using System.Data.Entity.Migrations.Infrastructure; + using System.Resources; + + [GeneratedCode("EntityFramework.Migrations", "6.1.3-40302")] + public sealed partial class AddUserCertificatesIndex : IMigrationMetadata + { + private readonly ResourceManager Resources = new ResourceManager(typeof(AddUserCertificatesIndex)); + + string IMigrationMetadata.Id + { + get { return "201805012001354_AddUserCertificatesIndex"; } + } + + string IMigrationMetadata.Source + { + get { return null; } + } + + string IMigrationMetadata.Target + { + get { return Resources.GetString("Target"); } + } + } +} diff --git a/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.cs b/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.cs new file mode 100644 index 0000000000..0de1cf29a4 --- /dev/null +++ b/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.cs @@ -0,0 +1,22 @@ +namespace NuGetGallery.Migrations +{ + using System; + using System.Data.Entity.Migrations; + + public partial class AddUserCertificatesIndex : DbMigration + { + public override void Up() + { + DropIndex("dbo.UserCertificates", new[] { "CertificateKey" }); + DropIndex("dbo.UserCertificates", new[] { "UserKey" }); + CreateIndex("dbo.UserCertificates", new[] { "CertificateKey", "UserKey" }, unique: true, name: "IX_UserCertificates_CertificateKeyUserKey"); + } + + public override void Down() + { + DropIndex("dbo.UserCertificates", "IX_UserCertificates_CertificateKeyUserKey"); + CreateIndex("dbo.UserCertificates", "UserKey"); + CreateIndex("dbo.UserCertificates", "CertificateKey"); + } + } +} diff --git a/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.resx b/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.resx new file mode 100644 index 0000000000..ca5b5e8207 --- /dev/null +++ b/src/NuGetGallery/Migrations/201805012001354_AddUserCertificatesIndex.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + H4sIAAAAAAAEAO1d3W4cuXK+D5B3GMxVEmwsy7vn4MSwEnhla48QyzYs7yJ3QmuGkjrb0z2nu8eWTpAny0UeKa8Q9u/wp4osdrN/RmdgwNA0yWKx+LFIFsmq//uf/33zb4+baPGNpVmYxGfL0xcvlwsWr5J1GN+fLXf53T//aflv//r3f/fm/XrzuPityfdjkY+XjLOz5UOeb1+fnGSrB7YJshebcJUmWXKXv1glm5NgnZy8evnyX05OT08YJ7HktBaLN192cR5uWPmD/zxP4hXb5rsgukrWLMrq7zzluqS6+BhsWLYNVuxs+XH3C8t/CaKIpU/LxdsoDDgP1yy6Wy6COE7yIOccvv41Y9d5msT311v+IYi+Pm0Zz3cXRBmrOX+9z05txMtXRSNO9gUbUqtdlicbR4KnP9ZSOVGLd5LtspUal9t7Lt/8qWh1Kbuz5TlL8/AuXAU5b79a3+vzKC3yytJ9IZT5YSGnJCn/1MCBo6b498PifBflu5SdxWyXp0H0w+Lz7jYKV//Onr4mv7P4LN5FkcgmZ5SnSR/4p89psuU1P31hdzXzPNNycSIXPFFLtuXEQlWrLuP8x1dL3oQoCm4j1uJAkMB1zpv0C4tZylu7/hzkOUt5N16uWSlJrXqlsuuH4PTrw25zu03DOG/q5RDk42i5uAoeP7D4Pn84W/7EB85F+MjWzYealV/jkI86XiZPd8xWG6mmV3/4I6kqTSpK3R+Db+F9KSSFCz7GUgEi2XLxhUVlxuwh3FaDUsTQjV7gIk02X5JIxqeW7+Y62aUrztnXhJD5a5Des1xuxpuT/YAwDhOFGnmoKOWOw8UMYEFUhHrNtArRuxNBUS31/lCAbjBKAnSDfpdxCbJeJBh5BjNozMK5IC6dhp3TWDsOMPOgeL8Jwujtep2yLPMwR1hGIF8wxndhumHrcevlQIj5X4aK/viTlzkQlG4UJd/Zuqn854QPjiB2pnWZvWMRy/sTeh8Xma845MOLYMXh83aXPxRoWdVryn7kPyZ8rD99Dla/B/fs8y578MBxIcbzCjolj+WoNPRmMWh6o+ZzkGXfk3T9hWUsn6jG94/bMC2b/K6cZ6r6i7+/hhsrvfOUFUrh13zlWvJDkOUXXOi8Pcl9GHegIJQ+T3b7BWjvaTdlpWrjpeBpt02/qSYLYaaVk/TJVUl3nU+vuC7jmIf5KinucyhzaZOgrWDlVGjJauLoU3ofxOFfy6Sr8L5C0hf2lx3LcpxLcymFc1NmuDXGEj5baOgISzGHNiJdZi7Sp5VujSO1id4ULy0gsm7mmcCsO5eF4k2/sXVrRoJZ1bLdfPrOF2ASw1geTeugGV3VT0HOINo6WRFp+RUWZZXkKsJrttqlfOH5OeEL6NDAkJTxSVPWSBZwkwHl67IZshopOm6MQPH6tUfs5y661a4tctwkDWE5UG1xvOzoG5DfgmhnqtWX7U9tK4uDOL9cD76ze8eyVRpuxX3LYHXt0TJwRfWyXV9xd4D4ebLZ7kpili1WsdFgmesqf78/+Rqufs+EEVLg2b7H4CMLaCd5rlnxn7CmLpNuRK24V9JqmjanaBm8WdYcdieaLRvZvXSaLsoWkmeKMvdxkrCO2rp/ek8V5QJQp2I5Wdrd/idbmQ56+J8etFNtznq7sihdam0eLAH9NYA62FAVQd7vFF1o4KtOV1kqPyPcVGm9hn1pSKutCeTRLxY6KgHz4Pg5WZvWB35GYAEaeElq0RBsb4braoodS8t8TTwf1zVCA8ekCPGbfc796AQzaOMUzuWqOqqm9zEiggdxmomxkwYx2waJGsVEZFoN85F9F5nrpHAgGn1WA2/XmzD2sgV1OULxNX1rFrayl+HTjM6DuxXROBZfcHzRjMTUFikgGsNY79yqfprkim1u+bDmzXFVH1rJaXWGRWFgeqZqRScN41W9yKz0oXSZleOw7xLj2WqpVtJDHN1YR29nVWTVQ2KGm6qFEO+GbBr3prze1i+d1isedc3i5yBjNbQbBCyJnaKKeZhuURe4lC6kn5aXWpzKuo1nErM9D1n2E0+HuerwJqm5zFATT08WBPs5zLaq73H0tn2UETR1TxWtHYiTB5tW8miu6nc8ePrqT0Msry6z64cglXq4793Izym7Cx/9je3mQofv2x/qJGW9JkJluL5w+YXdh1meGu7Z6DXCRU2NgUoQmgYW6zUjAxTJ2gIoe9QXtiPw8ZXFu+R7HCXBusstUk1N8L4M70Ifl7E/JKvfO9DponAAoAIqB8+ljUxD1o5qh874voCZ9SYfifk2s/uFv7/sQj4TXYf3TuLXypkbo2QntUktM9pdRk/Tgf2eo3EW6TMduE4BR7Vve5egdZSHs4jtUxrePwx/lDjFdSravTQ/7eODmgUZ+5jk+/tbg1XmcTZ+/1jINIhqdP2aRoNz/+cge3gb3SdpmD9sTEsZH4+vi8rGv3p5uUriMUTJV0DFreXeFwoaOtd5UcgbNbbh+vuVX+Y8ES3vPW7Xo+ukot736xCs1lIyXLE4G2eEhmtWV/eFbZO0N74+BPH9rlwT4CPRx2gvFwPiY8dR+rTWnRchR2f4VyYo5OL+raOo+M/ibs8Y3Vz0bRbytj+NpPa/sGB9xbhcyilHA5X9OLC4HV0D8+2qcEITxP3tVte7zSZIh78x9jW4H35p8DXMI+Mw83I5vl6Wj/6S4mOSboKIj7G1Nw5st/WK2eYD+8aAC8UkfV3utAbvdklbjzGaP/BNiA8LzueUpdXSuS+ti6jQ2zFbFy/pk3R4mbcVvmNbFvNdXPXSbqxa61mnqG74Wq/C+DwK+UbVPu5+8jHuutys9eSZoZYrX23mu0zgQTRDVYnu221mcKRDvmbXwBu3kt20eTSLWJOEWb/adFdLl80TT0NfyqezJ/rWwVgU87iyKY9VA59yRp1RMR3lVMrkyqqk043MSjlvWvubxjSYT7MRGjO7nn/X5f7Mpwu+4TfLvMr0ZGiAkgNjXc3WkWn5QGso0zrSBrMd3rEl9SRhgLucUYe7mI7CXcrk/GZ9ty2QxtYXKf/9PUl/N3Lc5jLARcuDCVvP6O2xYUNQfcwgfkcF2v+NYU2p0uquJvmq1NEwT1ow9L8DHBidY3VcyNlUQ991BDKitHVGH/S20+iTK4L3JY8oHgfFXm4nkKwP11u2GtzSUcG7nR/819drfDotUrGRCq5k+wxXxXTsNmKlwsdBO86gNfmoc7WmFlfRBatsN4YohqyBzu7Ok82GefHV7LjRc9ji7UtY9nhNRmyFieTueAFpuD0qiX11ke9Bh3XUXke91Wup6+sSo+FalqNNxX3Akawq2vDsA9na6uEK2brYEbLjTLVdLNvFRJzlwWbbe3Ye53hwrBOY8a6vjXmVbKzLQyNeKHm2lxpGvfZ3vAEx4o2/Gd1h9Hq/yO3im7/rVh63Eu7nRMj2ATtO8mX0b+gjtn8x2cait5OA4ovrErFIOa4PD/kUwPn+1hSnDU5nkJg1Ezyo7DNgBNuv26hpCx6HzgHb2LudMyPaHD+Q7oTQgjb9XT7PfETirI5gi0m9Y7AA0FWFHEegE6R0T/5Okb7koke4dbEF+V0wDHRKcr279en03bNH/SEGqu8YGlA8ClOsjU7D+XxXIvOCFXs0akiKfZnjAJ7VfHEVxHzNgEwZQrfd7DMKru2BdN2/PZTJqzsCsQboriSUbmRTu0jZZ6C4vl2Xix2Hi8Vev++23tPeEM/h3+6KPi9iMEbRU81s7ycu8SrarfvTGcjEjN/nF2cOX+NYC/ZnGuy+b2vLYxVyfAHwb8iNaSVTkV46qtYr1yzPy94n6ii52FFHWcYZe8zBt8+expyr+al0CeTqnBkoe+z38eeTwn84HEWmmytkjj8/9ETvzuccqKMcXQ7iyxkP8QKMgZt9Ac2GB+bD7HhwZg/eAsktscxexCKk9lHnMfOFKwnA5GZq5cxNVLKTmqeW8XHIVj1FdX+sUJQ6KmrSM99PsbsigSn9/ORBPwfZKHG4VCtceB8HBQ5GqNrwpLYWo2lUV5luhLzaSFazYKNXyzeEu8S6EsNrTiWHhVs/Voq3q1Xh8stRvUiljuplZPVSS7+3jvGnreakNWrpgENRwu2NWmA/IE35tGFpzOzuTcCk+sCqZNWHZKFxjag+XJvsNvpSpXFlcZldRMF91vaj29KlouIzLMev8Zql0RPHpQh1uV8qT/eNbvwWhFHlvq48HjpbvtQ6UirQ+gups5+as/Nc4TqoLC91Cf3itlTigjPE1k25Yn1Ql/tR77Cqa8SPb7MsWYVlwWbcqsE05brfx+uFLbLmU+vJpA4+e8U7I9wWUeLzp7PlP2kNMhBtjXl7omKgT5ny6VKdHD7FVQcsqiCmfFMaZKtgrasQLp21/IXPJyyt6uE72WJTEsa5PvmE8SrcBpGFf6UcddoqGGurUFOaJ5W5pUModSshbXUu2soUsdmk9OZEgBgFedUWzIwPZd/VG2/ynkygVx2zyuRevnihD+IeYJEqHw8nkgwp1e6NQpOgQ42njfUoGlybokBMUMGiclvgMr1OQhgfA2pIZ1Cqbq+tTII2OEwsBg1LzNg9QOSozHTsmcPNjqawjGyMgSejoCkMSAGVJ0GWHNgX63A4ju+YqgbjBQCdG6o7IQ+UxxiIAxtPqVgIaz0dzowhY439TYoGa8UjrtEcgtTuKzFHwB1K65HlMRoeKXKjMGOPgT0XsFo0JS0i84TqkxhluhvUfepYkiRnAXQnbSxHVp8E46YgrRh2SBFbYdC4KGNSvN59NUA870EASWj9GEgkSIe0f56FpgUjgTrgogPuBtGq5oilEFbHA+nU6DxAWKKRNrH+t4fd3GMACDNL33pbw3ZaVhUKbQeZQFdiLFLBi0ByAa/30CVjqGws2bS+Qy086g7JNSmMpLdQz6YaQ4033UG0FiKXMRQWIgFK1eLz90nUVMO76F/e1tOgs3kr/AgDD3RRL5q5BSYH2xwbeBkTTICQKdWrsRImBZXkZ9XW93BkgKm1GuwKVuNKdLU8qHqDpDQmLCF5HJKiQ/wTWmBg8w6q4UF5CuGsCW3eEbEKva9HYP+jTtxrzh0GlpbqI2JkHdJneBsbMuIwN3bgIQ131eeVBUioAywNQq3XUGewosFYDgemSBNGBCjSUYcITeM5PJB3UFBOcwpvYGICTB3WvQ7IgZd1rQ16+5p8rS1xhavFyhXfoKtsSD5jrrIhSRySbtO9f1n63uAKTOt/weOZs54zhLayol/Xdx2GKUXb29R8F8vKpIp9Ko1+mKocjovnYrPWH1c5mciHVfOWcH4d0N4Hmka5jQhVo1QcVL/2EH82SK7dcK+LZ1SuJz5K2ZGOftRaBz8D0k/mALZcThCh4iOcJ4LV4iPcoc8cb2BBPuawZlgdzsm9rnp7pMvL5rFu9tfvLQ0YQ2taOmv+c712Gcz9WqD7bUCHy6tzuC1lbfFYSDNKhsLER/a9EtqMAOeAtNldOJ3mYhQul0mA6ITAWcCv8sxs7GXZF7PbSgujBmClSPC71CjnH+H03dJONfccRpjGE7IiMVzD8DnSMBGNNtgwecx/dSFeHiHj0lQIfHSKw2AglBo5nAysFLmNgVmKdCh8zOIKEeiWGcWF0UezgFjROavDO2mTd+fBjAEEv6sWhg1FDdLpYGR28OA6oSWy2+AmN22UIU7u2IMzGYKOkSkj0mTsNgz4oaYoo//m7oOtD3hBMY0IV1AYpJlIcXw+pS0bdohqMSdbvKNqulD2WuxsvTb7V529XZHSihGPZIydR7T6TOxiBmqJy0KCWH4kRD+ndYVj+6aC/XNaYUDtUx0lu8AR9Zo86CDA/C4fpHZHGjMV2pEepbADuZqfEuWa90wLxnBXmsBbl8qNrjOOUTech4JdrAEj4hXrJwoLsivcGaCTerkJ9RlNxCZ4oc1aS+crSQ7yMPrqxfilOe7dc624v6aPW5LT39kPXkorxhjBlG5zGMaig+xJxjLmp9kJTfBM4xexLjONF7uLhYvJwDbz6eJ96X+bl8l5idYddPk1ZFnxnT1CwZx4T9ZetrPav7oKj4LuNcv1A45suXjfev2GzrU0pMmktMM1nZx2CEUgidGxFt67JwVbJriJtRAqXepCNGqHxJbiogNFiIrsYNFCzOxGTSdu9jRmqUy73ATVANyAIpM107MS0m5bQvSA+5wWsuBFU50waFuhkTaQo5JofKOghBoPIzRyknsClKb4tJ9GV3o9a6CsPHt2Im4nSyVYPXsziqB9V0ijWL1BQ6lV77dopNr3OgZ6wnsg29gp7x8Bw6W8DUTQy9KVU1hk0N1gm9LeH3OAWls8xKKRMgw59eDJQlCOggkRVMNr0jpWtLsY+la2nFGHdbFuMY7oallpISctoSByyipVIScscpRpVYxFsRCyiTMsGq9CWu6iESvatuxncm0BZyDTLJcFMtLKQt2oyW0ly6EOToeJADDJAmzLxtcuDZcNqQKFeinWu7Wqp3+gxcZgABLPWDgAWl8ZSQ0oAtj9PCAIgp96qQ1mT/VCS5QlqUEsZt/0AwhH9qAOCMXgYl3iHHaybuPYQAJotFGOXRtvXrIjAqH7AtdbSPIG7i44kv9vgaxlazO8cHG4OTivdpNDH2DSvE6PKGCTD2RAsmSXyVLTKU6TnWRJcY8sEIT2xENIjioyR1n5E5JFOj7EgvqNBURD8zErNcrqZVZoGGRqMMjL6ld2gJkTup+Ay8uQG28WXgiSGWxHMUjNQH54ubXeZnFhwQ5poSZoLml1sZBEoXmS1em0bHsThGShxYWBukkFGwI5Su0qFMg1qrjkl4zQ3sQim8hwueCePsHGgL4+u0oGdNGpE5Ob4ktCiD9JXFQUB5RQMy0uKPX2qlZIuyQtTifRKgYTZ2ukIkqzye/Y0gZIw8myqQEXpUcZqj4PcekZvSNCrcL8I+rtEazKdmFhLhFHERNmiICykVuCGSH6SGdoK0QjfdmKj+t8KZ9dTYvZe+t8iRgOl7oNvkSjuzXDxWNxgQa1CneCprdMPBixywt3ezboELONLeKgso0mGmRGGj6w9y7iZgS+EGfdL2gX43xvSLQ7cYPCxug2iihJo7cpa3sxf1O+5Yp5mBoAnkTfUiQrA1jSxTAAERjEAgFWhIPY2Jsdza+QDyrE5mp1V6UZRE0OqxQQ6WfXFmOryUfVUIcgoEckioW6i2Hasz16dAsq4NKHIionGfkSzgiWU8HFDCYGwAGNzrbsgsa92bLTGVGbVbz5aah+8xBps9n/h84+6vrDXRKojw6FlGfjldH7CXT2TvaWIp+eU/ylEE10dMIjiRByvwGJzuqmQ26ZyVGHKCrpRpRJVCbXHEPc5LA75cClZCplbaKhsEGCpB2Sg/uO4ZdMoB8IC/II2yajx4i+yDNtjywd0X2XBDskwLdHBAcG0H7F7MJAx4Nysc++ITI7LRhuo259FE8UpdtoJlIYTMDTDm6II/UJNlHsxpfbVjFgb7d9ixt7rT0crLXnWLg8zS+3oLahb7eg47b62qxdYuhrrcGlRLDPGZ+qGlpDsMm5i2gkO5zxVSogKvorVqltpHesQgvVC9kGsZFerg4AL+xNJFVoxkFoe0DpUVR+BmHxgrco3D7pa9PenFyvHtgmqD+8OeFZVmyb74LoKlmzKGsSroLttnh4sC9Zf1lcFwY+vr765+vl4nETxdnZ8iHPt69PTrKSdPZiE67SJEvu8herZHMSrJOTVy9f/svJ6enJpqJxspLGs/oAsa0pT1I+spRUXjXn9CJMs/xdkAe3QfHk5ny90bKVH4QHjLL0WvE2tQFvFPXOa14iNIWKv2unQ7tfWF6/yXhhuK6yl+QFb9yGxXnZTgZvWPXSvPz1KoiCtHk4Kj5cPU+i3SY2vGTFy18/BKdfH3ab220aFtpGJKWm0aliFM3U3pwoIlI74kTrCWVoqL1L6nuCDYLY/+rbU3cMWCkMhQPVC6lIyuahFKfauuUVyaG+eidFgFu3X2bF35/u/kHr/3/s2OvjdXX5ouDtep2yLJMJySkO3RyvkvguTDdsjRNHM7nBKeZ/6XiqvrrKIIqS78UrP10GTQqd4mVWz+UyOeGzA3dxcBux0lfCRbDiYCkuYBYvh1b1Vlli2JaZXu/HhI/zp3pl+3mXPaitATM4Sv28QkHJ3Nfkd6Y2B8lDr+VzkGXfk3RdHE/mQA1Qeh/q7x+3YbVnf1deZzXXpeZ2UNEpK0xbv+YrRT0L3+nUPgRZfsFFzdYfkvsw1qhC6XTqQsnzahsj0tZT/U8FLvO2eHLWQX0bT/twGVH1+ESToviqsfuKGH1WS1kQGwoPNTm6rljQFXD56l5a+wLv8E0UfguinUKi/uTABYuDOL9UtPj+K53SO5at0nCrTz9SgsN0WfZsrgh6/9VZLYI60WlyKhQzU1dEzUdHOtXMFa5+h+iJiW4KmwNxravp6utsFEf9ELyzzqhc3rirC6TcYDumVjvpGyY5iU5z78xSJIe7uMQpXe9u/5Ot1A1985FOp14JV77jZGpK0mzwJ7+Y7gxDyWeSOxrNxYcC5c/JWiFQfXFYudUP8DVOpAQHIDLNrMTcjEm+oPw1AdslfJ4NhF0fg3dY59pe8Duue+3k0E0n+y4S0voHSnfQX+sN37xA/S6nOOh98/6119a1Fp6+j5QSZgNT4JplZ2zqPt7cAUmggc68Jgj2wJ8cilLBtSFKIE7xMiuRq5qZ6o9HHPfCsRcA90Lu9JBF8DoKWCcCAPDwoTMOdGeQ7nAg0BhqBenD7HCZXT8EqeQPU+5/LdmF9ueU3YWPKsnm62wwRXy1Q0QV4dobAVckKkMhSzVDORqgku9xlARrwJysJLlg6TeWhnehfmiz/+5C7UOy+l2n1XydGzL7o7E7AsdDHQB5jSSWx2UxtX1Kw/uHXF1EtZ/HN2j6Ntl+YRELMvYxyVUrqZwy7Zh+/5gXx8FR3ae/ppFqgdXT6dT/HGQPb6P7JA3zh41MWElyo6mTctI8qyTWGtp+dNJgxQWYXNNg9Vd3Std5cTgN02vSOlBlG66gXyFkm8Su3Jqpy1kczffbtT6ypQQ3eu/XIUiu+e5ArfKfooFI/O6A6HCtetuW4K0nu7Q7vt+VviDkVjdfHWaG3W0U6ncchM/Os8xFyLER/lW7BKAkOtBNk8LMqfWL+N1Fg2+TLCy8m2gElSQn7fWFBesrdhlXilVTZVWyq/mgOHarQfJ2VdxVDWJ1H2HI5mJd3myCVFkQtB8drMvBvTIrVl8cKIS5qirrTw67N5Zm2nzffnS5CZRugoiDdQ1SBJJdTgQK5fmBfWP6yZma5qzByj0lqMPqFGeKlYrCNKOQ6kI50/V2/c1xJ5xWSy9gN9ymOJz9REGes5itW4d/0gGQltqBsuwWDiRv8hxHqkP2RgTWYXJYZKrjKozPo5BvbMBhoaeOf/EEvIXY4Q5iLSG+3sl3GbZjElLHumo87Z4Z987ptnOuI6d03j9j5QfeRWM4cD0UCdQ7vNWXufW2xc2mW5cLgW06d7uJxmF0fT8jYK1Xr7dsBS5xqgSXRVvx6EqIaCOv35TEucFTcZPZF6DylqwzRi1kDgOmfu9aXxenntr2Zf/VbQcHLEY7rULPk81Gu5zTfpwp2L3BvDfAx4P2oUyXezerffuoiYDWuY9QAoehfrzdQg/5njcPNlvVrNB+Htc8Ae4rO2wnfR63eL/Z7skY79ckO39Dov/jpb9NM2L/46QhDrmGMpD7Plbpcigw7ayLOQR3m3HL10mdp1u49GHMtfTVlS+j+7R4EVyZ9wXNfm/cGTkGEkPB50A3/ZgzVeq1xCLorns3wcX+Znc6oGfnzn0CBC927yEKkaH6y9c+pb8Ovt7dIst4OWWYi7BTPdEWvWd2RqEY8NodfsbSf7N6QnVD2rdzul+4tBEYqosEXOinfEra3G5zvt3lSfHyhadFTzWzms0CyOFgJIhX0W6t3RVuv7rc1ND2y+hGeaLBoAa17zwYZEIdBoONwGD6ij3mhgtxQPJsOg/0y9p34yBS6753MFMZePc5sApqPDODz2E7+ANQPONqZKH0bm8JCzeN+FPCKvXZviRUXM32HScVnT73BeDyQ42N+p7RJ82S3n52pvXzk8aUnOKCpSBTdwfNN4edRngfB/kuVU9z959ng0bFLW1nNEp0OqDRUv6A0Fi3BIOkmDwl0ueIUdltseX9bRtVnPrIti1gfUqLhfnSlIU5crsuPxqGNbpg1xbCbDnqwWztVbors6gpB+dP9U/tjAUoZIIFDaYihCewRETgtfTFBHW56IgKA8OHiwssArjD3TahFOUOm/naDy53cwjx7vqjIOcRHuYw5F3ZrOlMBBIgcjDxJGtfwPXACpcwGg64p7648YgCNBhyVx7deePbxHVY9OriMvu4i6Kz5V0QqU9bDE0fZObR4uE6TEFaWeJcdNpxMkLC6B7ArIQE5j2k6Yka8Je8oIWLW5e3BBcfpAUkVP2BrH1B1v2o3B6DoTfCxICrhgPlJgt0Tgyd5gMi1eOyjjgSbRz17MiCyNg9B4f3tB/HCpkNx67E7YspGGhXYdoO8hx72RRe9JDmAizqGcXYK2Qn+s7B51gkNlpPHVhT9bgWQMKtzXPpiTffjhstQpeapTXC1V/a322Erjo6lhS2q5RMEYRr0U6CQLisKstywYXwLVwXobKun7KcbV4UGV5c/yWq3i7vM/AhGN6xrIpbcbZ89fL01XLxNgqDrIqpVgcCe73aZcVpeBwneR1xjRAZ7PTHIjIYW29O1OLu8cUKKlm2lsIECNbtRrswJK7SG96XKiIapHxhdwsUVW9O1JJvIGwWPJwty6BX5ZD+hfGery5l5IXPDNHvfYG+wsNMi8ATI301RFdV1bcgXT0E6XJxFTx+YPF9/nC2/OmlSDxP9YtMKm0DXbXLXl/Ga/Z4tvyvsujrxeV/3MhRtFtSP/AR9msc/mXHc33lTCz+W+Ly1R/+aJGBaIY2drcxlNYBd7n6RF6oitAtWoBzmVw9W/6w+JRyBfB68RLqL0eG2xl4UE5PnTl1QtKzgY8ce6uqKIYUhjIUKRoDDfHluZo24JeB7h9/6iiaJvRXRfs2zJ3pCDG/uhOxhvXqThqM4tWDUyRil6Fzfnzl2udQ0K6BK1AjdVXVFS+K8rAAnxt98eV4P0pQZK5+FPV4XIIC8qNDsVhOB6xJe8xr0hTbYUqtYkn51X715Xa6qqZx2saa8jsHSI9z/ZLeA8Iv3daFqzpYEWCeJ5vtrizgqpCboFX9tIIWq6qZIu4VvUBVWlWcKhemyOoFiP10wJpFiR/lpl+kwr20zP4mqBsHTTmscgpe2vg+8hj8h03w+I+utJRAVRaKfWY5POjTAaOxiiDloR+kCFJukBKK9kIV2y9uuix1faJSiDvlJou2oMPgJiOYHtOJhmh79CY7wCEabiLTKfRSjHLoKDdWxLK9mLDuuCiaTScr3avH5+o+GLOEaaIByxrgBgYjGsXGDsOeGPQJQLkhzmOhLduLiTaMT3d9+hwwPBZ4nwdyO8N2IsySAWEJ2XTA6z+7YeD01Z869IQWBKqPxbUJ/+S9X633qQ64Zy1mmS7dqsRtodoRoS7dx2DqA4wm+tJQwHg2YMBuuLmpaZhKz4Vu69XPw7ZrNCucwULZiXHZC54Hgt6GKhRSyQN/iq85k6JyvttQucTzbOtunTx6aPw+6FIv5ScFWvJAqYl75IspDwQlR39DD2oxulLPUzzBjaePwaLHVOoj0iaakmmEOA86wYni0P2keZTEzi5o1AQnqV40ueQi1Y+qbKIsgb1O4wp1g9rHYFp7QPVhMC0dofogVLkt9nu61/p79HsqC0RaIldAs4jLAZfAVYCDPiu3c156SQ+w5IVo1vNejhxbqTsdPZqSjxMWOIaST8py5CQPlPWgSQaA/+QM8GFuadCuKPS+BKaHWeq+Su93b1Qu3UcswDX6TlvMPQE6N66beyjM0uFv8bvLvPe5RaDf2iQoDdduw8IkHbuuh+XcyVbosGKqYjb5XYxpXpz7kHdFnyEA0hGAPcyApAu0tF1JG3mp+3SKrkt7m5HaEEwTgPbZwBWaaRwPN1xFCMYcOmARTj7ip1u6CwGSeqqaQWwNPreMfk9XfB96+DSpe7a4zt4o6P3A6G/HQuh6PDT1cZZfc7fxRMV1EsFt/l4n3+LLceadeJ9OtooPsOdH/LIdMAq8bmJRkPWwwA1ke9OdFB1wJw5t8bJF/zlg0U34vtK6e+yyr5cDEXkmDl7S9Ak0NM7PASNs6MFpir1zwGJTvX45niRJpXsN09leWYTDA/U4dG4DA3Wn0XUfSEa7KbjOAaMdCM4z+GIWj2xzwIKc7WiVQuq4cSMU7cUCFH7HjROdgrfXhVXMHk8GpIHfZRkC3xzw0BFClvS0x8gRRdwgJpbtifYq6I4FUrRV9T6miTs5MqwMEWyOsGJg/JtO2NoT6AWwWaB8HGQS4tvQEEp0zq0XRDzs2uEN1+g67ek0Znjo6PkFYtc+Jnsx99jLs132zbanydFnqAMbjxij58YDt1AGdFuT6yiuC/bqUJF1t/r3JUccwKfHEfzsRrB7OI6BJ2caXg50rp4JMJ0Oa7qebuFxDAC0wDEu7L084ZFLy7MjpqpiQ/SWMcQGrdOs8TE8aunZWOXnp5VFBzbZZLt3ggz8tF+IQtFsRQv3mDeoD9738XpRDCTJTW/NehHo4YX4uXRJvY3CFa/5bKnHKvsUVzvoReVvkdMMslWgR/0uA3JgfFT+PEUW6i9y7f+kEeV9xtKK1XPe2Vyfh3ok289pGK/CbRDprVayUtFQNKelqqY0z1FyqXGUmhRHoHqdLWlFtDYxSKFKKNgpjcoobEr9KPZW9UHurJcvXpxq/TVVl0NBkybu7L3lf5J+3mPtBpjuHPt6ILXgrJ4ODS2OmsgQ3GoExIjeblsPrYPqCMm/rkhLTjhsDOBOhJEKJb+6kyChDM9Xs6wusOahOY7IgSoUfBBPhxsHl8POWMJVibFWkbA5I0F9zRszdOkjDNg9PM8FTPPUTIMA8dA0V28Uyi65J8Gf2IbaXa0VeLJlAuvtzEWx6d61RbJA6iDIMZhcBoKQxas4Uus8VBcAnYkwg4DlGaLkgOChhzgvbQVwQF+hW3Wf1GLvAqmjTHjwjRUjZ9B07AmCFr/dA8GQcGsHqZl4EjgpLKH4zTNZbc0LfKOtr7rjbdo1FXA2S9R90MG72M1g+igQxO5zWbgbEIbWSwoDoZJ0sw2pm3wRZmKYzlgXzg+Io+nDPsibhUa8aXzAWDoX6sxx4VX7UAT4aFKG1GljognyFonUKHp4mBRGgl9P/FKAkEc6dhO/O53jOIDT1xE/1s5hIUE75WeyZ9ZJASF5Dp69chE8fQK8iKnPRclgvk2RWueiaKQ77c19a+oiHnx+K3a0kmFMBJou69s4VbMPiVGDV9JhAUt+zYCwYXq+MD2ALct7MiCOoDWA9pDgir+fmRKvsAuSGU7o/VT94U7r7up5LjN77fb2cCDW+OkFGGmTngusQJ/EBwKowW8TTo2Hsa1dLmCYh5FLcO55AJvRon6Ii+r7c1EpZWsOSZ+0HjR9TlE01aI57xRJConPBRuIE1YzQG4mR8hoU82IBs+xJ5fDmVWkU7r6m3V2me858oiYmvic+HmcC9de9NeFe6DneI/hlHJ+fPo8EWpx8fFcIHt4dxomBeXUtxpoKJx2btbvEgKNeS4XXm06nVbgOV+H7arHZ307FkQ07QBphtP/IaF6qmWBN1zPaomgx5yYr8cAID6GyomafNhrAFtAEKTamb0D9vVic+7P5EaDRbfXcR/Z96rgjKAxz1e8U72gmwBApOpmAZuCc+ueYerZqfhLq7/6eNiIgb0dIpXNYP6p0GJZj2u9BfXUM0ILuQNHRUuRcWK0FP8JF7fnOSspTGpsGG7wH6K2cb3kP63SEbilo6nDW4xni62x33R0gdgs3nYIzl1vroI4uCecO4nx/iSgid/Hcfqn+OFFuBnwWSIa+3AgoBk9D2NAs7kangxoMz4tmh5a43mW7ICpiedHKXomZD0/QKu5EhIUANzzvMJhCoWKVDkrm7eo2ax3hmY3d04Gt4lmTheYzWLirBkW4362sSVnOXMCDEMaVk4/7BkUahKlXim86GzQ9Tym0/nAcOKbZ86onNXsCgFUCWh71IJG+M1eC0IRjqfEWtX7+2ifc8ZXHXYW9HBRpTwLTEHRdZEa5QCvM8AR9R2Ba1fO7l2Je19NcPu/7pTpwCHFi1aCG89S08gBrkUmlJTD1jSGON5IjUBk6/kgqv+0hauWIyLMiJhy7pH8hRc9sydYBuIpzxNvviZ+L07hWOnokvzAw2X08ljuHSXvy1iAvEzOS7C0sW4la3YRpln+LsiD20CLIF2Vuma5fl65XLxvwwtCp8zXqwe2Cc6W69uEd3kVpVA6w9bxItelnRJr9Wk5oDq1s3NKvUhleA12smIUMl1yQiIouDbdXlEdF0+ro/4OkS+T7JTlYEhaBXIyVI+Yw16dOaKJVr05O8SOORqMjT3gErDGE5AHYkTL5lK7sVpbffaKgGdRWn1AHqhaLZu9dtB2qNUP5oI4gN7YUHnA6zXWRabf+LvFamnSDXU1Xn+JNYoOMLFaxTyGmiW/oMTqFV9dGAdKNgMTUk5XNqwMUKomV9q6DsIqbTMYKq3yOEi8cjWDVVmlGuqrHO0Q6xL8l2AVClkMtba5CLqqvOmqq6fyM6iRiku9pBWB+gwIXB+ombDVgpSP0n/S0bC+ehBTweXDPgO5LlzrqRkMNZJ14C9BFLG0+FDYgIFK1QxQpXIeMlJl+zsGVjmXAa9iRgdVXO2WcTVcpRtVcJHFXqOyP9dqVNKhGqUsHZYQyBIbyUdaSlTxFLqsJxBm0JzEdYUbQ0YfqqSZ0X2yqsq5zlyAWwSq7E7JwlNcSrgjjLZupBUj4c9tSbl/lgHOI/iE1aQ6TRkIwrUclqmjubLptHHLLDu1zL410ysUbBzKvvdG3EkL2cQ98A0eRhsJJt42UP6smW32paVNeFmw/qIa3eWmkJtZHXqjLQTOxAGTnsBd9WGSBqmR5YFGGYPP92yYcz93aCIcCh1oKCFmes/mQjacsqSc0LvJpYl5b+rRmypnONgmmq1OSLNd4tn2EgXFhFZSMmccXlQ4RBxCNj8TYZkiBQNSIgcWNp3JIK3Tpl6JBmoBLekAqUOIhiqT8YSBSMFD822RXgFJOAWHlRqEGn7LdgGpBuEYd4AwPWi4+hRZ/e7IRWTQU6WeOmc6wQBbHTua7IXwphq2ZGVDwXSD6CxbeYzm8OLDkWUv5BNbsxBQG4kQFwccrBBqCsQ6RQTy0YpYuknx1lzpPBpvsuGhMPacW9yciN/tzaeIrUeT5bMfvM147Diffa0faokUxFRfAjAHL8MF4hD0DGooeHwmtlXJYBcdwQiJ0lezDyxcq451CMhlEIWh2c9DoO3AIgoSuW3rb/j2Q3V3sahxinCBGCMa+RSFckYsFm+TfDcfM0xB2QZYq4zZZDl8jGHekvIN1tvi2bxYtvruq9F6tBO84ZbIKD4br10WEAkIid763oLzwQA+7FIMjl1B3Ii0+Uffwo0oFCS8AlFElOAMU+x5Tyl7Ov0u9TAiddv+UoIHDLMPHk1obs7rSQYpE4GJLHo2qNMKjCVsF+uficB4Q3+uAtfvvaGTK5bV52DH7+q15dVk/6dbTodag5xljX80o7WL1vqhmj3cIUzZhPIOjkFl65l8g1y8PtSWqj76bCKuKPVMBuWlMgsxOn4T9bdBSDO1jL57E7P0amn9r9uY/IpCd2/Ifki9mq5HForBBSYkE6rHTFkk+iXuSiTid9NNJfhGn0rD0/mNyVkjVSQDLOYnEYJiCZJXUKgsTKXGWzfC1/lFefnbcYNu8CxgIZgdPA2cUURQU4JdteF7YoJrNz+7YOixg4grOX0QcRBHEbXo6NaXGYhQ9X5FFJ/RadYh40vze4ELxOwiw48Q5Lc18vlzleK54QRbr9ETkY9GjGjZNfrPAURA97fTs//Bt1VlUSVlGBHAwCf5hjmYhnd0bmK7JNrRTUpPsXW8eGoQW+HGqSDSevpo096cVA9t6g/8Z56kfDxeJWsWZeXXNydfeFeFG1b9esey0irflOA0Y1Z6kdoTbfJcxndJ491E4ajJ0iS3HgzyYB3kwdtiIxescp68YllWvu78LYh2rLiwf8vWl/GnXb7d5bzJbHMbSQa7wlGKqf43JxrPbz5tS5n6aAJnM+RNYJ/in3dhtG75vggi9coGRqLwwPIL49+rviyWIez+qaX0MYmJhGrxtY5jvrLNNio26Z/i6+Ab68Ibh+8Hdh+snvj3b+G6wDJGxN4RstjfvAuD+zTYZDWNfXn+k2N4vXn81/8HrUd9PC2oAgA= + + + dbo + + \ No newline at end of file diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj index edbbae2509..76030fa30e 100644 --- a/src/NuGetGallery/NuGetGallery.csproj +++ b/src/NuGetGallery/NuGetGallery.csproj @@ -441,8 +441,8 @@ ..\..\packages\NuGet.Protocol.4.3.0-preview1-2524\lib\net45\NuGet.Protocol.dll - - ..\..\packages\NuGet.Services.Contracts.2.10.0\lib\net45\NuGet.Services.Contracts.dll + + ..\..\packages\NuGet.Services.Contracts.2.25.0-master-30191\lib\net45\NuGet.Services.Contracts.dll ..\..\packages\NuGet.Services.KeyVault.1.0.0.0\lib\net45\NuGet.Services.KeyVault.dll @@ -460,23 +460,18 @@ ..\..\packages\NuGet.Services.Platform.Client.3.0.29-r-master\lib\portable-net45+wp80+win\NuGet.Services.Platform.Client.dll True - - ..\..\packages\NuGet.Services.ServiceBus.2.10.0\lib\net45\NuGet.Services.ServiceBus.dll + + ..\..\packages\NuGet.Services.ServiceBus.2.25.0-master-30191\lib\net45\NuGet.Services.ServiceBus.dll - - ..\..\packages\NuGet.Services.Validation.2.10.0\lib\net45\NuGet.Services.Validation.dll + + ..\..\packages\NuGet.Services.Validation.2.25.0-master-30191\lib\net45\NuGet.Services.Validation.dll - - ..\..\packages\NuGet.Services.Validation.Issues.2.10.0\lib\net45\NuGet.Services.Validation.Issues.dll + + ..\..\packages\NuGet.Services.Validation.Issues.2.25.0-master-30191\lib\net45\NuGet.Services.Validation.Issues.dll ..\..\packages\NuGet.Versioning.4.3.0-preview1-2524\lib\net45\NuGet.Versioning.dll - - False - ..\..\packages\ODataNullPropagationVisitor.0.5.4237.2641\lib\net40\ODataNullPropagationVisitor.dll - True - False ..\..\packages\Owin.1.0\lib\net40\Owin.dll @@ -700,6 +695,7 @@ + @@ -802,6 +798,7 @@ + @@ -832,6 +829,18 @@ 201803202317063_AddEnableMultiFactorAuthentication.cs + + + 201804171613337_AddCertificateRegistration.cs + + + + 201804241507196_RemoveUserCertificateIsActive.cs + + + + 201805012001354_AddUserCertificatesIndex.cs + @@ -841,7 +850,11 @@ 201711021733062_ApiKeyOwnerScope.cs + + + + @@ -849,9 +862,13 @@ + + + + @@ -1071,8 +1088,13 @@ + + + + + Designer @@ -1639,7 +1661,6 @@ - @@ -1972,6 +1993,15 @@ 201803202317063_AddEnableMultiFactorAuthentication.cs + + 201804171613337_AddCertificateRegistration.cs + + + 201804241507196_RemoveUserCertificateIsActive.cs + + + 201805012001354_AddUserCertificatesIndex.cs + @@ -1987,6 +2017,7 @@ + @@ -2060,6 +2091,11 @@ + + + + + @@ -2164,9 +2200,7 @@ Designer - - Designer @@ -2424,6 +2458,6 @@ copy "$(ProjectDir)..\Bootstrap\dist\css\bootstrap.css" "$(ProjectDir)Content\gallery\css" >NUL copy "$(ProjectDir)..\Bootstrap\dist\css\bootstrap-theme.css" "$(ProjectDir)Content\gallery\css" >NUL copy "$(ProjectDir)..\Bootstrap\dist\js\bootstrap.js" "$(ProjectDir)Scripts\gallery" >NUL - + \ No newline at end of file diff --git a/src/NuGetGallery/RequestModels/EditPackageRequest.cs b/src/NuGetGallery/RequestModels/EditPackageRequest.cs index 17436fcb44..931b9af584 100644 --- a/src/NuGetGallery/RequestModels/EditPackageRequest.cs +++ b/src/NuGetGallery/RequestModels/EditPackageRequest.cs @@ -15,6 +15,7 @@ public class EditPackageRequest public string PackageId { get; set; } public string PackageTitle { get; set; } public string Version { get; set; } + public PackageRegistration PackageRegistration { get; set; } public IList PackageVersions { get; set; } public bool IsLocked { get; set; } } diff --git a/src/NuGetGallery/RouteName.cs b/src/NuGetGallery/RouteName.cs index 90951142c5..0c64146b92 100644 --- a/src/NuGetGallery/RouteName.cs +++ b/src/NuGetGallery/RouteName.cs @@ -78,6 +78,7 @@ public static class RouteName public const string ConfirmAccount = "ConfirmAccount"; public const string SigninAssistance = "SigninAssistance"; public const string ChangeEmailSubscription = "ChangeEmailSubscription"; + public const string ChangeMultiFactorAuthentication = "ChangeMultiFactorAuthentication"; public const string ErrorReadOnly = "ErrorReadOnly"; public const string Error500 = "Error500"; public const string Error404 = "Error404"; @@ -90,5 +91,14 @@ public static class RouteName public const string Downloads = "Downloads"; public const string AdminDeleteAccount = "AdminDeleteAccount"; public const string UserDeleteAccount = "DeleteAccount"; + public const string AddUserCertificate = "AddUserCertificate"; + public const string DeleteUserCertificate = "DeleteUserCertificate"; + public const string GetUserCertificate = "GetUserCertificate"; + public const string GetUserCertificates = "GetUserCertificates"; + public const string AddOrganizationCertificate = "AddOrganizationCertificate"; + public const string DeleteOrganizationCertificate = "DeleteOrganizationCertificate"; + public const string GetOrganizationCertificate = "GetOrganizationCertificate"; + public const string GetOrganizationCertificates = "GetOrganizationCertificates"; + public const string SetRequiredSigner = "SetRequiredSigner"; } -} +} \ No newline at end of file diff --git a/src/NuGetGallery/Scripts/gallery/async-file-upload.js b/src/NuGetGallery/Scripts/gallery/async-file-upload.js index 6912818f94..a114e047ac 100644 --- a/src/NuGetGallery/Scripts/gallery/async-file-upload.js +++ b/src/NuGetGallery/Scripts/gallery/async-file-upload.js @@ -1,358 +1,360 @@ -'use strict'; - -var AsyncFileUploadManager = new function () { - var _actionUrl; - var _cancelUrl; - var _submitVerifyUrl; - var _isWebkitBrowser = false; // $.browser.webkit is not longer supported on jQuery - var _iframeId = '__fileUploadFrame'; - var _uploadFormId; - var _uploadFormData; - var _pollingInterval = 250; // in ms - var _pingUrl; - var _failureCount; - var _isUploadInProgress; - - this.init = function (pingUrl, formId, jQueryUrl, actionUrl, cancelUrl, submitVerifyUrl) { - _pingUrl = pingUrl; - _uploadFormId = formId; - _actionUrl = actionUrl; - _cancelUrl = cancelUrl; - _submitVerifyUrl = submitVerifyUrl; - - $('#file-select-feedback').on('dragenter', function (e) { - e.preventDefault(); - e.stopPropagation(); - - $(this).removeAttr('readonly'); - }); - - $('#file-select-feedback').on('dragleave', function (e) { - e.preventDefault(); - e.stopPropagation(); - - $(this).attr('readonly', 'readonly'); - }); - - - $('#file-select-feedback').on('dragover', function (e) { - e.preventDefault(); - e.stopPropagation(); - }); - - - $('#file-select-feedback').on('drop', function (e) { - e.preventDefault(); - e.stopPropagation(); - $(this).attr('readonly', 'readonly'); +var AsyncFileUploadManager = (function () { + 'use strict'; + + return new function () { + var _actionUrl; + var _cancelUrl; + var _submitVerifyUrl; + var _isWebkitBrowser = false; // $.browser.webkit is not longer supported on jQuery + var _iframeId = '__fileUploadFrame'; + var _uploadFormId; + var _uploadFormData; + var _pollingInterval = 250; // in ms + var _pingUrl; + var _failureCount; + var _isUploadInProgress; + + this.init = function (pingUrl, formId, jQueryUrl, actionUrl, cancelUrl, submitVerifyUrl) { + _pingUrl = pingUrl; + _uploadFormId = formId; + _actionUrl = actionUrl; + _cancelUrl = cancelUrl; + _submitVerifyUrl = submitVerifyUrl; + + $('#file-select-feedback').on('dragenter', function (e) { + e.preventDefault(); + e.stopPropagation(); + + $(this).removeAttr('readonly'); + }); - clearErrors(); - var droppedFile = e.originalEvent.dataTransfer.files[0]; - $('#file-select-feedback').attr('value', droppedFile.name); + $('#file-select-feedback').on('dragleave', function (e) { + e.preventDefault(); + e.stopPropagation(); - prepareUploadFormData(); - _uploadFormData.set("UploadFile", droppedFile); - cancelUploadAsync(startUploadAsync, startUploadAsync); - }); + $(this).attr('readonly', 'readonly'); + }); - $('#file-select-feedback').on('click', function () { - $('#input-select-file').click(); - }); - $('#input-select-file').on('change', function () { - clearErrors(); - var fileName = window.nuget.getFileName($('#input-select-file').val()); + $('#file-select-feedback').on('dragover', function (e) { + e.preventDefault(); + e.stopPropagation(); + }); + + + $('#file-select-feedback').on('drop', function (e) { + e.preventDefault(); + e.stopPropagation(); + $(this).attr('readonly', 'readonly'); + + clearErrors(); + var droppedFile = e.originalEvent.dataTransfer.files[0]; + $('#file-select-feedback').attr('value', droppedFile.name); - if (fileName.length > 0) { - $('#file-select-feedback').attr('value', fileName); prepareUploadFormData(); - // Cancel any ongoing upload, and then start the new upload. - // If the cancel fails, still try to upload the new one. + _uploadFormData.set("UploadFile", droppedFile); cancelUploadAsync(startUploadAsync, startUploadAsync); - } else { - resetFileSelectFeedback(); + }); + + $('#file-select-feedback').on('click', function () { + $('#input-select-file').click(); + }); + + $('#input-select-file').on('change', function () { + clearErrors(); + var fileName = window.nuget.getFileName($('#input-select-file').val()); + + if (fileName.length > 0) { + $('#file-select-feedback').attr('value', fileName); + prepareUploadFormData(); + // Cancel any ongoing upload, and then start the new upload. + // If the cancel fails, still try to upload the new one. + cancelUploadAsync(startUploadAsync, startUploadAsync); + } else { + resetFileSelectFeedback(); + } + }); + + if (InProgressPackage != null) { + bindData(InProgressPackage); } - }); + } - if (InProgressPackage != null) { - bindData(InProgressPackage); + function resetFileSelectFeedback() { + $('#file-select-feedback').attr('value', 'Browse or Drop files to select a package...'); } - } - function resetFileSelectFeedback() { - $('#file-select-feedback').attr('value', 'Browse or Drop files to select a package...'); - } + function prepareUploadFormData() { + var formData = new FormData($('#' + _uploadFormId)[0]); + _uploadFormData = formData; + } - function prepareUploadFormData() { - var formData = new FormData($('#' + _uploadFormId)[0]); - _uploadFormData = formData; - } + function startUploadAsync(callback, error) { + // Shortcut the upload if the nupkg input doesn't have a value + if ($('#input-select-file').val() == null) { + return; + } - function startUploadAsync(callback, error) { - // Shortcut the upload if the nupkg input doesn't have a value - if ($('#input-select-file').val() == null) { - return; - } + startProgressBar(); - startProgressBar(); + $.ajax({ + url: _actionUrl, + type: 'POST', - $.ajax({ - url: _actionUrl, - type: 'POST', + data: _uploadFormData, - data: _uploadFormData, + cache: false, + contentType: false, + processData: false, - cache: false, - contentType: false, - processData: false, + success: function (model, resultCodeString, fullResponse) { + bindData(model); + endProgressBar(); + if (callback) { + callback(); + } + }, - success: function (model, resultCodeString, fullResponse) { - bindData(model); - endProgressBar(); - if (callback) { - callback(); - } - }, + error: handleErrors.bind(this, error) + }); + } - error: handleErrors.bind(this, error) - }); - } + function submitVerifyAsync(callback, error) { + $.ajax({ + url: _submitVerifyUrl, + type: 'POST', - function submitVerifyAsync(callback, error) { - $.ajax({ - url: _submitVerifyUrl, - type: 'POST', + data: new FormData($('#verify-metadata-form')[0]), - data: new FormData($('#verify-metadata-form')[0]), + cache: false, + contentType: false, + processData: false, - cache: false, - contentType: false, - processData: false, + success: function (model, resultCodeString, fullResponse) { + if (callback) { + callback(model); + } + }, - success: function (model, resultCodeString, fullResponse) { - if (callback) { - callback(model); - } - }, + error: handleErrors.bind(this, error) + }); + } - error: handleErrors.bind(this, error) - }); - } + function cancelUploadAsync(callback, error) { + clearErrors(); - function cancelUploadAsync(callback, error) { - clearErrors(); + $.ajax({ + url: _cancelUrl, + type: 'POST', - $.ajax({ - url: _cancelUrl, - type: 'POST', + data: new FormData($('#cancel-form')[0]), - data: new FormData($('#cancel-form')[0]), + cache: false, + contentType: false, + processData: false, - cache: false, - contentType: false, - processData: false, + success: function (model, resultCodeString, fullResponse) { + bindData(model); + if (callback) { + callback(); + } + }, - success: function (model, resultCodeString, fullResponse) { - bindData(model); - if (callback) { - callback(); - } - }, - - error: handleErrors.bind(this, error) - }); - } - - function handleErrors(errorCallback, model, resultCodeString, fullResponse) { - bindData(null); - - switch (resultCodeString) { - case "timeout": - displayErrors(["The operation timed out. Please try again."]); - break; - case "abort": - displayErrors(["The operation was aborted. Please try again."]); - break; - default: - displayErrors(model.responseJSON); - break; + error: handleErrors.bind(this, error) + }); } - if (fullResponse.status >= 500) { - displayErrors(["There was a server error."]); - } + function handleErrors(errorCallback, model, resultCodeString, fullResponse) { + bindData(null); + + switch (resultCodeString) { + case "timeout": + displayErrors(["The operation timed out. Please try again."]); + break; + case "abort": + displayErrors(["The operation was aborted. Please try again."]); + break; + default: + displayErrors(model.responseJSON); + break; + } - endProgressBar(); - if (errorCallback) { - errorCallback(); - } - } + if (fullResponse.status >= 500) { + displayErrors(["There was a server error."]); + } - function displayErrors(errors) { - if (errors == null || errors.length < 1) { - return; + endProgressBar(); + if (errorCallback) { + errorCallback(); + } } - clearErrors() - - var failureContainer = $("#validation-failure-container"); - var failureListContainer = document.createElement("div"); - $(failureListContainer).attr("id", "validation-failure-list"); - $(failureListContainer).attr("data-bind", "template: { name: 'validation-errors', data: data }"); - failureContainer.append(failureListContainer); - ko.applyBindings({ data: errors }, failureListContainer); - failureContainer.removeClass("hidden"); - } - - function clearErrors() { - $("#validation-failure-container").addClass("hidden"); - $("#validation-failure-list").remove(); - - var warnings = $('#warning-container'); - warnings.addClass("hidden"); - warnings.children().remove(); - } - - function bindData(model) { - $("#verify-package-block").remove(); - $("#submit-block").remove(); - $("#verify-warning-container").addClass("hidden"); - $("#verify-collapser-container").addClass("hidden"); - $("#submit-collapser-container").addClass("hidden"); - - if (model != null) { - var reportContainerElement = document.createElement("div"); - $(reportContainerElement).attr("id", "verify-package-block"); - $(reportContainerElement).attr("class", "collapse in"); - $(reportContainerElement).attr("aria-expanded", "true"); - $(reportContainerElement).attr("data-bind", "template: { name: 'verify-metadata-template', data: data }"); - $("#verify-package-container").append(reportContainerElement); - ko.applyBindings({ data: model }, reportContainerElement); - - var submitContainerElement = document.createElement("div"); - $(submitContainerElement).attr("id", "submit-block"); - $(submitContainerElement).attr("class", "collapse in"); - $(submitContainerElement).attr("aria-expanded", "true"); - $(submitContainerElement).attr("data-bind", "template: { name: 'submit-package-template', data: data }"); - $("#submit-package-container").append(submitContainerElement); - ko.applyBindings({ data: model }, submitContainerElement); - - $('#verify-cancel-button').on('click', function () { - $('#verify-cancel-button').attr('disabled', 'disabled'); - $('#verify-cancel-button').attr('value', 'Cancelling'); - $('#verify-cancel-button').addClass('.loading'); - $('#verify-submit-button').attr('disabled', 'disabled'); - $('#input-select-file').val(""); - resetFileSelectFeedback(); - cancelUploadAsync(); - }); + function displayErrors(errors) { + if (errors == null || errors.length < 1) { + return; + } - $('#verify-submit-button').on('click', function () { - $('#verify-cancel-button').attr('disabled', 'disabled'); - $('#verify-submit-button').attr('disabled', 'disabled'); - $('#verify-submit-button').attr('value', 'Submitting'); - $('#verify-submit-button').addClass('.loading'); - submitVerifyAsync(navigateToPage, bindData.bind(this, model)); - }); + clearErrors() - $('#iconurl-field').on('change', function () { - $('#icon-preview').attr('src', $('#iconurl-field').val()); - }); + var failureContainer = $("#validation-failure-container"); + var failureListContainer = document.createElement("div"); + $(failureListContainer).attr("id", "validation-failure-list"); + $(failureListContainer).attr("data-bind", "template: { name: 'validation-errors', data: data }"); + failureContainer.append(failureListContainer); + ko.applyBindings({ data: errors }, failureListContainer); + failureContainer.removeClass("hidden"); + } - $("#verify-warning-container").removeClass("hidden"); - $("#verify-collapser-container").removeClass("hidden"); - $("#submit-collapser-container").removeClass("hidden"); + function clearErrors() { + $("#validation-failure-container").addClass("hidden"); + $("#validation-failure-list").remove(); - window.nuget.configureExpanderHeading("verify-package-section"); - window.nuget.configureExpanderHeading("submit-package-form"); + var warnings = $('#warning-container'); + warnings.addClass("hidden"); + warnings.children().remove(); } - bindReadMeData(model); - } - - function navigateToPage(verifyResponse) { - document.location = verifyResponse.location; - } - - function startProgressBar() { - _isUploadInProgress = true; - _failureCount = 0; - - setProgressIndicator(0, ''); - $("#upload-progress-bar-container").removeClass("hidden"); - setTimeout(getProgress, 100); - } - - function endProgressBar() { - $("#upload-progress-bar-container").addClass("hidden"); - _isUploadInProgress = false; - } - - function getProgress() { - $.ajax({ - type: 'GET', - dataType: 'json', - url: _pingUrl, - success: onGetProgressSuccess, - error: onGetProgressError - }); - } - - function onGetProgressSuccess(result) { - if (!result) { - return; - } + function bindData(model) { + $("#verify-package-block").remove(); + $("#submit-block").remove(); + $("#verify-warning-container").addClass("hidden"); + $("#verify-collapser-container").addClass("hidden"); + $("#submit-collapser-container").addClass("hidden"); + + if (model != null) { + var reportContainerElement = document.createElement("div"); + $(reportContainerElement).attr("id", "verify-package-block"); + $(reportContainerElement).attr("class", "collapse in"); + $(reportContainerElement).attr("aria-expanded", "true"); + $(reportContainerElement).attr("data-bind", "template: { name: 'verify-metadata-template', data: data }"); + $("#verify-package-container").append(reportContainerElement); + ko.applyBindings({ data: model }, reportContainerElement); + + var submitContainerElement = document.createElement("div"); + $(submitContainerElement).attr("id", "submit-block"); + $(submitContainerElement).attr("class", "collapse in"); + $(submitContainerElement).attr("aria-expanded", "true"); + $(submitContainerElement).attr("data-bind", "template: { name: 'submit-package-template', data: data }"); + $("#submit-package-container").append(submitContainerElement); + ko.applyBindings({ data: model }, submitContainerElement); + + $('#verify-cancel-button').on('click', function () { + $('#verify-cancel-button').attr('disabled', 'disabled'); + $('#verify-cancel-button').attr('value', 'Cancelling'); + $('#verify-cancel-button').addClass('.loading'); + $('#verify-submit-button').attr('disabled', 'disabled'); + $('#input-select-file').val(""); + resetFileSelectFeedback(); + cancelUploadAsync(); + }); + + $('#verify-submit-button').on('click', function () { + $('#verify-cancel-button').attr('disabled', 'disabled'); + $('#verify-submit-button').attr('disabled', 'disabled'); + $('#verify-submit-button').attr('value', 'Submitting'); + $('#verify-submit-button').addClass('.loading'); + submitVerifyAsync(navigateToPage, bindData.bind(this, model)); + }); + + $('#iconurl-field').on('change', function () { + $('#icon-preview').attr('src', $('#iconurl-field').val()); + }); + + $("#verify-warning-container").removeClass("hidden"); + $("#verify-collapser-container").removeClass("hidden"); + $("#submit-collapser-container").removeClass("hidden"); + + window.nuget.configureExpanderHeading("verify-package-section"); + window.nuget.configureExpanderHeading("submit-package-form"); + } - var percent = result.Progress; + bindReadMeData(model); + } - if (!result.FileName) { - return; + function navigateToPage(verifyResponse) { + document.location = verifyResponse.location; } - setProgressIndicator(percent, result.FileName); - if (percent < 100) { - setTimeout(getProgress, _pollingInterval); + function startProgressBar() { + _isUploadInProgress = true; + _failureCount = 0; + + setProgressIndicator(0, ''); + $("#upload-progress-bar-container").removeClass("hidden"); + setTimeout(getProgress, 100); } - else { + + function endProgressBar() { + $("#upload-progress-bar-container").addClass("hidden"); _isUploadInProgress = false; } - } - function onGetProgressError(result) { - if (++_failureCount < 3) { - setTimeout(getProgress, _pollingInterval); + function getProgress() { + $.ajax({ + type: 'GET', + dataType: 'json', + url: _pingUrl, + success: onGetProgressSuccess, + error: onGetProgressError + }); + } + + function onGetProgressSuccess(result) { + if (!result) { + return; + } + + var percent = result.Progress; + + if (!result.FileName) { + return; + } + + setProgressIndicator(percent, result.FileName); + if (percent < 100) { + setTimeout(getProgress, _pollingInterval); + } + else { + _isUploadInProgress = false; + } + } + + function onGetProgressError(result) { + if (++_failureCount < 3) { + setTimeout(getProgress, _pollingInterval); + } } - } - - function setProgressIndicator(percentComplete, fileName) { - $("#upload-progress-bar").width(percentComplete + "%") - .attr("aria-valuenow", percentComplete) - .text(percentComplete + "%"); - } - - // obsolete - function constructIframe(jQueryUrl) { - var iframe = document.getElementById(_iframeId); - if (iframe) { - return; + + function setProgressIndicator(percentComplete, fileName) { + $("#upload-progress-bar").width(percentComplete + "%") + .attr("aria-valuenow", percentComplete) + .text(percentComplete + "%"); } - iframe = document.createElement('iframe'); - iframe.setAttribute('id', _iframeId); - iframe.setAttribute('style', 'display: none; visibility: hidden;'); - - $(iframe).load(function () { - var scriptRef = document.createElement('script'); - scriptRef.setAttribute("src", jQueryUrl); - scriptRef.setAttribute("type", "text/javascript"); - iframe.contentDocument.body.appendChild(scriptRef); - - var scriptContent = document.createElement('script'); - scriptContent.setAttribute("type", "text/javascript"); - scriptContent.innerHTML = "var _callback,_error, _key, _pingUrl, _fcount;function start(b,c,e){_callback=c;_pingUrl=b;_error=e;_fcount=0;setTimeout(getProgress,200)}function getProgress(){$.ajax({type:'GET',dataType:'json',url:_pingUrl,success:onSuccess,error:_error})}function onSuccess(a){if(!a){return}var b=a.Progress;var d=a.FileName;if(!d){return}_callback(b,d);if(b<100){setTimeout(getProgress,200)}}function onError(a){if(++_fcount<3){setTimeout(getProgress,200)}}"; - iframe.contentDocument.body.appendChild(scriptContent); - }); - - document.body.appendChild(iframe); - } -}; \ No newline at end of file + // obsolete + function constructIframe(jQueryUrl) { + var iframe = document.getElementById(_iframeId); + if (iframe) { + return; + } + + iframe = document.createElement('iframe'); + iframe.setAttribute('id', _iframeId); + iframe.setAttribute('style', 'display: none; visibility: hidden;'); + + $(iframe).load(function () { + var scriptRef = document.createElement('script'); + scriptRef.setAttribute("src", jQueryUrl); + scriptRef.setAttribute("type", "text/javascript"); + iframe.contentDocument.body.appendChild(scriptRef); + + var scriptContent = document.createElement('script'); + scriptContent.setAttribute("type", "text/javascript"); + scriptContent.innerHTML = "var _callback,_error, _key, _pingUrl, _fcount;function start(b,c,e){_callback=c;_pingUrl=b;_error=e;_fcount=0;setTimeout(getProgress,200)}function getProgress(){$.ajax({type:'GET',dataType:'json',url:_pingUrl,success:onSuccess,error:_error})}function onSuccess(a){if(!a){return}var b=a.Progress;var d=a.FileName;if(!d){return}_callback(b,d);if(b<100){setTimeout(getProgress,200)}}function onError(a){if(++_fcount<3){setTimeout(getProgress,200)}}"; + iframe.contentDocument.body.appendChild(scriptContent); + }); + + document.body.appendChild(iframe); + } + }; +}()); diff --git a/src/NuGetGallery/Scripts/gallery/certificates.js b/src/NuGetGallery/Scripts/gallery/certificates.js new file mode 100644 index 0000000000..489e0060e4 --- /dev/null +++ b/src/NuGetGallery/Scripts/gallery/certificates.js @@ -0,0 +1,162 @@ +var CertificatesManagement = (function () { + 'use strict'; + + var _addCertificateUrl; + var _getCertificatesUrl; + var _model; + + return new function () { + this.init = function (addCertificateUrl, getCertificatesUrl) { + _addCertificateUrl = addCertificateUrl; + _getCertificatesUrl = getCertificatesUrl; + + $('#input-select-file').on('change', function () { + clearErrors(); + + var filePath = $('#input-select-file').val(); + + if (filePath) { + var uploadForm = $('#uploadCertificateForm')[0]; + var formData = new FormData(uploadForm); + + uploadForm.reset(); + + addCertificateAsync(formData); + } + }); + + listCertificatesAsync(); + } + + function listCertificatesAsync() { + $.ajax({ + method: 'GET', + url: _getCertificatesUrl, + dataType: 'json', + cache: false, + contentType: false, + processData: false, + success: function (response) { + applyModel(response); + }, + error: onError.bind(this) + }); + } + + function deleteCertificateAsync(model) { + clearErrors(); + + if (model.CanDelete) { + $.ajax({ + method: 'DELETE', + url: model.DeleteUrl, + cache: false, + data: window.nuget.addAjaxAntiForgeryToken({}), + dataType: 'json', + success: function (response) { + listCertificatesAsync(); + }, + error: onError.bind(this) + }); + } + } + + function addCertificateAsync(data) { + clearErrors(); + + $.ajax({ + method: 'POST', + url: _addCertificateUrl, + data: data, + cache: false, + contentType: false, + processData: false, + complete: function (xhr, textStatus) { + switch (xhr.status) { + case 201: + case 409: + listCertificatesAsync(); + break; + + default: + onError(xhr, textStatus); + break; + } + } + }); + } + + function applyModel(data) { + if (_model) { + _model.certificates(data); + } else { + _model = { + certificates: ko.observableArray(data), + deleteCertificate: deleteCertificateAsync, + hasCertificates: function () { + return this.certificates().length > 0; + } + }; + + ko.applyBindings(_model, document.getElementById('certificates-container')); + } + + var certificatesHeader; + + if (data) { + certificatesHeader = data.length + " certificate"; + + if (data.length !== 1) { + certificatesHeader += "s"; + } + } else { + certificatesHeader = ""; + } + + $('#certificates-section-header').text(certificatesHeader); + } + + function onError(model, resultCodeString) { + switch (resultCodeString) { + case "timeout": + displayErrors(["The operation timed out. Please try again."]); + break; + case "abort": + displayErrors(["The operation was aborted. Please try again."]); + break; + default: + displayErrors(model.responseJSON); + break; + } + + if (model.status >= 500) { + displayErrors(["There was a server error."]); + } + } + + function displayErrors(errors) { + if (errors == null || errors.length === 0) { + return; + } + + clearErrors(); + + var failureContainer = $("#validation-failure-container"); + var failureListContainer = document.createElement("div"); + $(failureListContainer).attr("id", "validation-failure-list"); + $(failureListContainer).attr("data-bind", "template: { name: 'validation-errors', data: data }"); + failureContainer.append(failureListContainer); + ko.applyBindings({ data: errors }, failureListContainer); + failureContainer.removeClass("hidden"); + } + + function clearErrors() { + $("#validation-failure-container").addClass("hidden"); + $("#validation-failure-list").remove(); + + var warnings = $('#warning-container'); + warnings.addClass("hidden"); + warnings.children().remove(); + } + }; +}()); \ No newline at end of file diff --git a/src/NuGetGallery/Scripts/gallery/clamp.js b/src/NuGetGallery/Scripts/gallery/clamp.js index a2c9814070..fdbb5510ee 100644 --- a/src/NuGetGallery/Scripts/gallery/clamp.js +++ b/src/NuGetGallery/Scripts/gallery/clamp.js @@ -7,6 +7,7 @@ */ (function(root, factory) { + 'use strict'; if (typeof define === 'function' && define.amd) { // AMD define([], factory); @@ -18,6 +19,7 @@ root.$clamp = factory(); } }(this, function() { + 'use strict'; /** * Clamps a text node. * @param {HTMLElement} element. Element containing the text node to clamp. diff --git a/src/NuGetGallery/Scripts/gallery/common.js b/src/NuGetGallery/Scripts/gallery/common.js index 427b31d4bb..5a34352c8a 100644 --- a/src/NuGetGallery/Scripts/gallery/common.js +++ b/src/NuGetGallery/Scripts/gallery/common.js @@ -50,10 +50,48 @@ } else { error.insertAfter(element); } + }, + showErrors: function (errorMap, errorList) { + this.defaultShowErrors(); + + // By default, showErrors adds an aria-describedby attribute to every field that it validates, even if it finds no issues. + // This is a problem, because the aria-describedby attribute will then link to an empty element. + // This code removes the aria-describedby if the describing element is missing or empty. + var i; + for (i = 0; this.errorList[i]; i++) { + removeInvalidAriaDescribedBy(this.errorList[i].element); + } + + for (i = 0; this.successList[i]; i++) { + removeInvalidAriaDescribedBy(this.successList[i]); + } } }); } + function removeInvalidAriaDescribedBy(element) { + var describedBy = element.getAttribute("aria-describedby"); + if (!describedBy) { + return; + } + + var ids = describedBy.split(" ") + .filter(function (describedById) { + if (!describedById) { + return false; + } + + var describedByElement = $("#" + describedById); + return describedByElement && describedByElement.text(); + }); + + if (ids.length) { + element.setAttribute("aria-describedby", ids.join(" ")); + } else { + element.removeAttribute("aria-describedby"); + } + } + nuget.parseNumber = function (unparsedValue) { unparsedValue = ('' + unparsedValue).replace(/,/g, ''); var parsedValue = parseInt(unparsedValue); @@ -280,88 +318,89 @@ window.nuget = nuget; initializeJQueryValidator(); -})(); - -$(function () { - // Use moment.js to format attributes with the "datetime" attribute to "X time ago". - $.each($('*[data-datetime]'), function () { - var $el = $(this); - var formats = window.nuget.getDateFormats($el.data().datetime); - if (!formats) { - return; - } - if (!$el.attr('title')) { - $el.attr('title', formats.title); - } + $(function () { + // Use moment.js to format attributes with the "datetime" attribute to "X time ago". + $.each($('*[data-datetime]'), function () { + var $el = $(this); + var formats = window.nuget.getDateFormats($el.data().datetime); + if (!formats) { + return; + } - if (formats.text) { - $el.text(formats.text); - } - }); + if (!$el.attr('title')) { + $el.attr('title', formats.title); + } - // Handle confirm pop-ups. - $('*[data-confirm]').delegate('', 'click', function (e) { - window.nuget.confirmEvent($(this).data().confirm, e); - }); + if (formats.text) { + $el.text(formats.text); + } + }); - // Select the first input that has an error. - $('.has-error') - .find('input,textarea,select') - .filter(':visible:first') - .focus(); - - // Handle Google analytics tracking event on specific links. - $.each($('a[data-track]'), function () { - $(this).click(function (e) { - var href = $(this).attr('href'); - var category = $(this).data().track; - if (window.nuget.isGaAvailable() && href && category) { - if (e.altKey || e.ctrlKey || e.metaKey) { - ga('send', 'event', category, 'click', href); - } else { - e.preventDefault(); - ga('send', 'event', category, 'click', href, { - 'transport': 'beacon', - 'hitCallback': window.nuget.createFunctionWithTimeout(function () { - document.location = href; - }) - }); + // Handle confirm pop-ups. + $('*[data-confirm]').delegate('', 'click', function (e) { + window.nuget.confirmEvent($(this).data().confirm, e); + }); + + // Select the first input that has an error. + $('.has-error') + .find('input,textarea,select') + .filter(':visible:first') + .focus(); + + // Handle Google analytics tracking event on specific links. + $.each($('a[data-track]'), function () { + $(this).click(function (e) { + var href = $(this).attr('href'); + var category = $(this).data().track; + if (window.nuget.isGaAvailable() && href && category) { + if (e.altKey || e.ctrlKey || e.metaKey) { + ga('send', 'event', category, 'click', href); + } else { + e.preventDefault(); + ga('send', 'event', category, 'click', href, { + 'transport': 'beacon', + 'hitCallback': window.nuget.createFunctionWithTimeout(function () { + document.location = href; + }) + }); + } } - } + }); }); - }); - // Show elements that require ClickOnce - (function () { - var userAgent = window.navigator.userAgent.toUpperCase(); - var hasNativeDotNet = userAgent.indexOf('.NET CLR 3.5') >= 0; - if (hasNativeDotNet) { - $('.no-clickonce').removeClass('no-clickonce'); - } - })(); + // Show elements that require ClickOnce + (function () { + var userAgent = window.navigator.userAgent.toUpperCase(); + var hasNativeDotNet = userAgent.indexOf('.NET CLR 3.5') >= 0; + if (hasNativeDotNet) { + $('.no-clickonce').removeClass('no-clickonce'); + } + })(); - // Don't close the dropdown on click events inside of the dropdown. - $(document).on('click', '.dropdown-menu', function (e) { - e.stopPropagation(); - }); + // Don't close the dropdown on click events inside of the dropdown. + $(document).on('click', '.dropdown-menu', function (e) { + e.stopPropagation(); + }); - $(document).on('keydown', function (e) { - var code = (e.keyCode || e.which); - var isValidInputCharacter = - ((code >= 48 && code <= 57) // numbers 0-9 - || (code >= 64 && code <= 90) // letters a-z - || (code >= 96 && code <= 111) // numpad - || (code >= 186 && code <= 192) // ; = , - . / ` - || (code >= 219 && code <= 222)) // [\ ] ' - && !e.altKey && !e.ctrlKey && !e.metaKey; - - if (isValidInputCharacter && document.activeElement == document.body) { - var searchbox = $("#search"); - searchbox.focus(); - var currInput = searchbox.val(); - searchbox.val(""); - searchbox.val(currInput); - } + $(document).on('keydown', function (e) { + var code = (e.keyCode || e.which); + var isValidInputCharacter = + ((code >= 48 && code <= 57) // numbers 0-9 + || (code >= 64 && code <= 90) // letters a-z + || (code >= 96 && code <= 111) // numpad + || (code >= 186 && code <= 192) // ; = , - . / ` + || (code >= 219 && code <= 222)) // [\ ] ' + && !e.altKey && !e.ctrlKey && !e.metaKey; + + if (isValidInputCharacter && document.activeElement == document.body) { + var searchbox = $("#search"); + searchbox.focus(); + var currInput = searchbox.val(); + searchbox.val(""); + searchbox.val(currInput); + } + }); }); -}); +}()); + diff --git a/src/NuGetGallery/Scripts/gallery/page-account.js b/src/NuGetGallery/Scripts/gallery/page-account.js index abfacc3f87..8dfe660b20 100644 --- a/src/NuGetGallery/Scripts/gallery/page-account.js +++ b/src/NuGetGallery/Scripts/gallery/page-account.js @@ -29,20 +29,20 @@ } // Set up the change password form. - var $enablePasswordLogin = $("#ChangePassword_EnablePasswordLogin[type=checkbox]"); + var $disablePasswordLogin = $("#ChangePassword_DisablePasswordLogin[type=checkbox]"); function setPasswordLoginReadonly() { - var enablePasswordLogin = $enablePasswordLogin[0]; - if (!enablePasswordLogin) { + var disablePasswordLogin = $disablePasswordLogin[0]; + if (!disablePasswordLogin) { return; } - var disabled = !enablePasswordLogin.checked; + var disabled = disablePasswordLogin.checked; $("#ChangePassword_OldPassword").prop('disabled', disabled); $("#ChangePassword_NewPassword").prop('disabled', disabled); $("#ChangePassword_VerifyPassword").prop('disabled', disabled); } $("#show-change-password-container").click(setPasswordLoginReadonly); - $enablePasswordLogin.change(setPasswordLoginReadonly); + $disablePasswordLogin.change(setPasswordLoginReadonly); setPasswordLoginReadonly(); // Set up the section expanders. diff --git a/src/NuGetGallery/Scripts/gallery/page-api-keys.js b/src/NuGetGallery/Scripts/gallery/page-api-keys.js index d919537bbe..986a24fa90 100644 --- a/src/NuGetGallery/Scripts/gallery/page-api-keys.js +++ b/src/NuGetGallery/Scripts/gallery/page-api-keys.js @@ -100,6 +100,11 @@ function (owner) { return owner.Owner.toUpperCase() == data.Owner.toUpperCase() }); + + if (existingOwner == null) { + existingOwner = { "Owner": data.Owner, "PackageIds": [] }; + } + this.PackageOwner(existingOwner); } else { this.PackageOwner(this.PackageOwners[0]); diff --git a/src/NuGetGallery/Scripts/gallery/page-edit-readme.js b/src/NuGetGallery/Scripts/gallery/page-edit-readme.js index c8ccf2cf67..317d27beff 100644 --- a/src/NuGetGallery/Scripts/gallery/page-edit-readme.js +++ b/src/NuGetGallery/Scripts/gallery/page-edit-readme.js @@ -1,307 +1,315 @@ -'use strict'; - -var EditReadmeManager = new function () { - var _currVersion; - var _viewModel; - var _changedState; - var _submitUrl; - var _cancelUrl; - var _submitting; - var _submitted = true; - - this.init = function (model, submitUrl, cancelUrl) { - _submitting = false; - _submitted = false; - _submitUrl = submitUrl; - _cancelUrl = cancelUrl; - _viewModel = model; - _changedState = {}; - bindData(_viewModel); - - $(window).on('beforeunload', confirmLeave); - - $('#verify-submit-button').attr('disabled', 'disabled'); - - $('#input-select-version').on('change', function () { - document.location = $(this).val(); - }); +var EditReadmeManager = (function () { + 'use strict'; + + return new function () { + var _currVersion; + var _viewModel; + var _changedState; + var _submitUrl; + var _cancelUrl; + var _submitting; + var _submitted = true; + + this.init = function (model, submitUrl, cancelUrl) { + _submitting = false; + _submitted = false; + _submitUrl = submitUrl; + _cancelUrl = cancelUrl; + _viewModel = model; + _changedState = {}; + bindData(_viewModel); + + $(window).on('beforeunload', confirmLeave); - $('input[type="text"], input[type="checkbox"], input[type="url"], input[type="file"], textarea').on('change keydown', function () { - $(this).addClass("edited"); - _changedState[$(this).attr('id')] = true; - $('#verify-submit-button').removeAttr('disabled'); - }); - } + $('#verify-submit-button').attr('disabled', 'disabled'); - this.isEdited = function () { - return Object.keys(_changedState).reduce(function (previous, key) { return previous || _changedState[key]; }, false); - } + $('#input-select-version').on('change', function () { + document.location = $(this).val(); + }); - function confirmLeave() { - var message = ""; - if (_submitting) { - message = "Your request is being submitted. Are you sure you want to leave?"; + $('input[type="text"], input[type="checkbox"], input[type="url"], input[type="file"], textarea').on('change keydown', function () { + $(this).addClass("edited"); + _changedState[$(this).attr('id')] = true; + $('#verify-submit-button').removeAttr('disabled'); + }); } - if (message !== "") { - return message; + this.isEdited = function () { + return Object.keys(_changedState).reduce(function (previous, key) { return previous || _changedState[key]; }, false); } - } - function submitAsync(callback, error) { - if (EditReadmeManager.isEdited()) { - if (!_submitting) { - _submitting = true; - $.ajax({ - url: _submitUrl, - type: 'POST', - - data: new FormData($('#edit-readme-form')[0]), - - cache: false, - contentType: false, - processData: false, - - success: function (model, resultCodeString, fullResponse) { - _submitting = false; - _submitted = true; - if (callback) { - callback(model); - } - }, - - error: handleErrors.bind(this, error) - }); + function confirmLeave() { + var message = ""; + if (_submitting) { + message = "Your request is being submitted. Are you sure you want to leave?"; } - } else { - if (callback) { - callback(); + + if (message !== "") { + return message; } } - } - function cancelEdit() { - navigateToPage({ location: _cancelUrl }); - } + function submitAsync(callback, error) { + if (EditReadmeManager.isEdited()) { + if (!_submitting) { + _submitting = true; + $.ajax({ + url: _submitUrl, + type: 'POST', + + data: new FormData($('#edit-readme-form')[0]), + + cache: false, + contentType: false, + processData: false, + + success: function (model, resultCodeString, fullResponse) { + _submitting = false; + _submitted = true; + if (callback) { + callback(model); + } + }, + + error: handleErrors.bind(this, error) + }); + } + } else { + if (callback) { + callback(); + } + } + } - function navigateToPage(editReadmeResponse) { - document.location = editReadmeResponse.location; - } + function cancelEdit() { + navigateToPage({ location: _cancelUrl }); + } - function displayErrors(errors) { - if (errors == null || errors.length < 1) { - return; + function navigateToPage(editReadmeResponse) { + document.location = editReadmeResponse.location; } - var failureContainer = $("#validation-failure-container"); - var failureListContainer = document.createElement("div"); - $(failureListContainer).attr("id", "validation-failure-list"); - $(failureListContainer).attr("data-bind", "template: { name: 'validation-errors', data: data }"); - failureContainer.append(failureListContainer); - ko.applyBindings({ data: errors }, failureListContainer); + function displayErrors(errors) { + if (errors == null || errors.length < 1) { + return; + } - failureContainer.removeClass("hidden"); - } + var failureContainer = $("#validation-failure-container"); + var failureListContainer = document.createElement("div"); + $(failureListContainer).attr("id", "validation-failure-list"); + $(failureListContainer).attr("data-bind", "template: { name: 'validation-errors', data: data }"); + failureContainer.append(failureListContainer); + ko.applyBindings({ data: errors }, failureListContainer); - function handleErrors(errorCallback, model, resultCodeString, fullResponse) { - bindData(null); - - _submitting = false; - switch (resultCodeString) { - case "timeout": - displayErrors(["The operation timed out. Please try again."]) - break; - case "abort": - displayErrors(["The operation was aborted. Please try again."]) - break; - default: - displayErrors(model.responseJSON); - break; + failureContainer.removeClass("hidden"); } - if ((fullResponse && fullResponse.status >= 500) || (model && model.status >= 500)) { - displayErrors(["There was a server error."]) + function handleErrors(errorCallback, model, resultCodeString, fullResponse) { + bindData(null); + + _submitting = false; + switch (resultCodeString) { + case "timeout": + displayErrors(["The operation timed out. Please try again."]) + break; + case "abort": + displayErrors(["The operation was aborted. Please try again."]) + break; + default: + displayErrors(model.responseJSON); + break; + } + + if ((fullResponse && fullResponse.status >= 500) || (model && model.status >= 500)) { + displayErrors(["There was a server error."]) + } + + if (errorCallback) { + errorCallback(); + } } - if (errorCallback) { - errorCallback(); + function bindData(model) { + + if (model == null) { + return; + } + + var submitContainerElement = document.createElement("div"); + $(submitContainerElement).attr("id", "submit-block"); + $(submitContainerElement).attr("class", "collapse in"); + $(submitContainerElement).attr("aria-expanded", "true"); + $(submitContainerElement).attr("data-bind", "template: { name: 'submit-package-template', data: data }"); + $("#submit-package-container").append(submitContainerElement); + ko.applyBindings({ data: model }, submitContainerElement); + + $('#verify-cancel-button').on('click', function () { + cancelEdit(); + }); + + $('#verify-submit-button').on('click', function () { + $('#verify-cancel-button').attr('disabled', 'disabled'); + $('#verify-submit-button').attr('disabled', 'disabled'); + $('#verify-submit-button').attr('value', 'Submitting'); + $('#verify-submit-button').addClass('.loading'); + submitAsync(navigateToPage); + }); + + bindReadMeData(model); } - } + }; +}()); + +var bindReadMeData = (function () { + 'use strict'; - function bindData(model) { - - if (model == null) { + function bindReadMeData(model) { + $("#import-readme-block").remove(); + $("#readme-collapser-container").addClass("hidden"); + + if (model == null) + { return; } - var submitContainerElement = document.createElement("div"); - $(submitContainerElement).attr("id", "submit-block"); - $(submitContainerElement).attr("class", "collapse in"); - $(submitContainerElement).attr("aria-expanded", "true"); - $(submitContainerElement).attr("data-bind", "template: { name: 'submit-package-template', data: data }"); - $("#submit-package-container").append(submitContainerElement); - ko.applyBindings({ data: model }, submitContainerElement); + model.SelectedTab = ko.observable('written'); + model.OnReadmeTabChange = function (_, e) { + model.SelectedTab($(e.target).data('source-type')); + return true; + }; - $('#verify-cancel-button').on('click', function () { - cancelEdit(); - }); + var readMeContainerElement = document.createElement("div"); + $(readMeContainerElement).attr("id", "import-readme-block"); + $(readMeContainerElement).attr("class", "collapse in"); + $(readMeContainerElement).attr("aria-expanded", "true"); + $(readMeContainerElement).attr("data-bind", "template: { name: 'import-readme-template', data: data }"); + $("#import-readme-container").append(readMeContainerElement); + ko.applyBindings({ data: model }, readMeContainerElement); - $('#verify-submit-button').on('click', function () { - $('#verify-cancel-button').attr('disabled', 'disabled'); - $('#verify-submit-button').attr('disabled', 'disabled'); - $('#verify-submit-button').attr('value', 'Submitting'); - $('#verify-submit-button').addClass('.loading'); - submitAsync(navigateToPage); - }); + $("#readme-collapser-container").removeClass("hidden"); - bindReadMeData(model); - } -} + window.nuget.configureExpanderHeading("readme-package-form"); -function bindReadMeData(model) { - $("#import-readme-block").remove(); - $("#readme-collapser-container").addClass("hidden"); + $("#ReadMeUrlInput").on("change blur", function () { + clearReadMeError(); + }); - if (model == null) - { - return; - } + $('#ReadMeFileText').on('click', function () { + $('#ReadMeFileInput').click(); + }); - model.SelectedTab = ko.observable('written'); - model.OnReadmeTabChange = function (_, e) { - model.SelectedTab($(e.target).data('source-type')); - return true; - }; + $('#ReadMeFileInput').on('change', function () { + clearReadMeError(); - var readMeContainerElement = document.createElement("div"); - $(readMeContainerElement).attr("id", "import-readme-block"); - $(readMeContainerElement).attr("class", "collapse in"); - $(readMeContainerElement).attr("aria-expanded", "true"); - $(readMeContainerElement).attr("data-bind", "template: { name: 'import-readme-template', data: data }"); - $("#import-readme-container").append(readMeContainerElement); - ko.applyBindings({ data: model }, readMeContainerElement); + displayReadMeEditMarkdown(); + var fileName = window.nuget.getFileName($('#ReadMeFileInput').val()); - $("#readme-collapser-container").removeClass("hidden"); + if (fileName.length > 0) { + $('#ReadMeFileText').attr('value', fileName); + } + else { + $('#ReadMeFileText').attr('placeholder', 'Browse or Drop files to select a ReadMe.md file...'); + } + }); - window.nuget.configureExpanderHeading("readme-package-form"); + $("#ReadMeTextInput").on("change", function () { + clearReadMeError(); + }) - $("#ReadMeUrlInput").on("change blur", function () { - clearReadMeError(); - }); + $("#preview-readme-button").on('click', function () { + previewReadMeAsync(); + }); - $('#ReadMeFileText').on('click', function () { - $('#ReadMeFileInput').click(); - }); + if ($("#ReadMeTextInput").val() !== "") { + previewReadMeAsync(); + } - $('#ReadMeFileInput').on('change', function () { - clearReadMeError(); + $("#edit-markdown-button").on('click', function () { + clearReadMeError(); + displayReadMeEditMarkdown(); + }); + } + + function previewReadMeAsync(callback, error) { + // Request source type is generated off the ReadMe tab ids. + var readMeType = $(".readme-tabs li.active a").data("source-type") - displayReadMeEditMarkdown(); - var fileName = window.nuget.getFileName($('#ReadMeFileInput').val()); + var formData = new FormData(); + formData.append("SourceType", readMeType); - if (fileName.length > 0) { - $('#ReadMeFileText').attr('value', fileName); + if (readMeType == "written") { + var readMeWritten = $("#ReadMeTextInput").val(); + formData.append("SourceText", readMeWritten); + } + else if (readMeType == "url") { + var readMeUrl = $("#ReadMeUrlInput").val(); + formData.append("SourceUrl", readMeUrl); } - else { - $('#ReadMeFileText').attr('placeholder', 'Browse or Drop files to select a ReadMe.md file...'); + else if (readMeType == "file") { + var readMeFileInput = $("#ReadMeFileInput"); + var readMeFileName = readMeFileInput && readMeFileInput[0] ? window.nuget.getFileName(readMeFileInput.val()) : null; + var readMeFile = readMeFileName ? readMeFileInput[0].files[0] : null; + formData.append("SourceFile", readMeFile); } - }); - $("#ReadMeTextInput").on("change", function () { - clearReadMeError(); - }) + $.ajax({ + url: "/packages/manage/preview-readme", + type: "POST", + contentType: false, + processData: false, + data: window.nuget.addAjaxAntiForgeryToken(formData), + success: function (model, resultCodeString, fullResponse) { + clearReadMeError(); + displayReadMePreview(model); + }, + error: function (jqXHR, exception) { + var message = ""; + if (jqXHR.status == 400) { + try { + message = JSON.parse(jqXHR.responseText); + } catch (err) { + message = "Bad request. [400]"; + } + } + displayReadMeError(message); + } + }); + } - $("#preview-readme-button").on('click', function () { - previewReadMeAsync(); - }); + function displayReadMePreview(response) { + $("#readme-preview-contents").html(response); + $("#readme-preview").removeClass("hidden"); - if ($("#ReadMeTextInput").val() !== "") { - previewReadMeAsync(); - } + $('.readme-tabs').children().hide(); - $("#edit-markdown-button").on('click', function () { + $("#edit-markdown").removeClass("hidden"); + $("#preview-html").addClass("hidden"); clearReadMeError(); - displayReadMeEditMarkdown(); - }); -} + } -function previewReadMeAsync(callback, error) { - // Request source type is generated off the ReadMe tab ids. - var readMeType = $(".readme-tabs li.active a").data("source-type") + function displayReadMeEditMarkdown() { + $("#readme-preview-contents").html(""); + $("#readme-preview").addClass("hidden"); - var formData = new FormData(); - formData.append("SourceType", readMeType); + $('.readme-tabs').children().show(); - if (readMeType == "written") { - var readMeWritten = $("#ReadMeTextInput").val(); - formData.append("SourceText", readMeWritten); - } - else if (readMeType == "url") { - var readMeUrl = $("#ReadMeUrlInput").val(); - formData.append("SourceUrl", readMeUrl); + $("#edit-markdown").addClass("hidden"); + $("#preview-html").removeClass("hidden"); } - else if (readMeType == "file") { - var readMeFileInput = $("#ReadMeFileInput"); - var readMeFileName = readMeFileInput && readMeFileInput[0] ? window.nuget.getFileName(readMeFileInput.val()) : null; - var readMeFile = readMeFileName ? readMeFileInput[0].files[0] : null; - formData.append("SourceFile", readMeFile); + + function displayReadMeError(errorMsg) { + $("#readme-errors").removeClass("hidden"); + $("#preview-readme-button").attr("disabled", "disabled"); + $("#readme-error-content").text(errorMsg); } - $.ajax({ - url: "/packages/manage/preview-readme", - type: "POST", - contentType: false, - processData: false, - data: window.nuget.addAjaxAntiForgeryToken(formData), - success: function (model, resultCodeString, fullResponse) { - clearReadMeError(); - displayReadMePreview(model); - }, - error: function (jqXHR, exception) { - var message = ""; - if (jqXHR.status == 400) { - try { - message = JSON.parse(jqXHR.responseText); - } catch (err) { - message = "Bad request. [400]"; - } - } - displayReadMeError(message); + function clearReadMeError() { + if (!$("#readme-errors").hasClass("hidden")) { + $("#readme-errors").addClass("hidden"); + $("#readme-error-content").text(""); } - }); -} - -function displayReadMePreview(response) { - $("#readme-preview-contents").html(response); - $("#readme-preview").removeClass("hidden"); - - $('.readme-tabs').children().hide(); - - $("#edit-markdown").removeClass("hidden"); - $("#preview-html").addClass("hidden"); - clearReadMeError(); -} - -function displayReadMeEditMarkdown() { - $("#readme-preview-contents").html(""); - $("#readme-preview").addClass("hidden"); - - $('.readme-tabs').children().show(); - - $("#edit-markdown").addClass("hidden"); - $("#preview-html").removeClass("hidden"); -} - -function displayReadMeError(errorMsg) { - $("#readme-errors").removeClass("hidden"); - $("#preview-readme-button").attr("disabled", "disabled"); - $("#readme-error-content").text(errorMsg); -} - -function clearReadMeError() { - if (!$("#readme-errors").hasClass("hidden")) { - $("#readme-errors").addClass("hidden"); - $("#readme-error-content").text(""); + $("#preview-readme-button").removeAttr("disabled"); } - $("#preview-readme-button").removeAttr("disabled"); -} + + return bindReadMeData; +}()); diff --git a/src/NuGetGallery/Scripts/gallery/page-manage-organization.js b/src/NuGetGallery/Scripts/gallery/page-manage-organization.js index a63598bce3..e22acfda95 100644 --- a/src/NuGetGallery/Scripts/gallery/page-manage-organization.js +++ b/src/NuGetGallery/Scripts/gallery/page-manage-organization.js @@ -258,7 +258,8 @@ // Set up the data binding. var manageOrganizationViewModel = new ManageOrganizationViewModel(initialData); - ko.applyBindings(manageOrganizationViewModel, document.body); + var manageOrganizationMembersContainer = $('#manage-organization-members-container'); + ko.applyBindings(manageOrganizationViewModel, manageOrganizationMembersContainer[0]); // Set up the Add Member textbox to submit upon pressing enter. var newMemberTextbox = $("#new-member-textbox"); diff --git a/src/NuGetGallery/Scripts/gallery/page-manage-packages.js b/src/NuGetGallery/Scripts/gallery/page-manage-packages.js index 65ffd41c0a..e3bf6898c4 100644 --- a/src/NuGetGallery/Scripts/gallery/page-manage-packages.js +++ b/src/NuGetGallery/Scripts/gallery/page-manage-packages.js @@ -29,7 +29,13 @@ : this.PackagesListViewModel.ManagePackagesViewModel.DefaultPackageIconUrl; this.PackageUrl = packageItem.PackageUrl; this.EditUrl = packageItem.EditUrl; + this.SetRequiredSignerUrl = packageItem.SetRequiredSignerUrl; this.ManageOwnersUrl = packageItem.ManageOwnersUrl; + this.RequiredSignerMessage = packageItem.RequiredSignerMessage; + this.AllSigners = packageItem.AllSigners; + this.ShowRequiredSigner = packageItem.ShowRequiredSigner; + this.ShowTextBox = packageItem.ShowTextBox; + this.CanEditRequiredSigner = packageItem.CanEditRequiredSigner; this.DeleteUrl = packageItem.DeleteUrl; this.CanEdit = packageItem.CanEdit; this.CanManageOwners = packageItem.CanManageOwners; @@ -39,6 +45,50 @@ return ko.unwrap(this.DownloadCount).toLocaleString(); }, this); + var requiredSigner = null; + + if (packageItem.RequiredSigner) { + if (this.ShowTextBox) { + requiredSigner = packageItem.RequiredSigner.OptionText; + } else { + requiredSigner = packageItem.RequiredSigner.Username; + } + } + + this._requiredSigner = ko.observable(requiredSigner); + + this.RequiredSigner = ko.pureComputed({ + read: function () { + return self._requiredSigner(); + }, + write: function (newSignerUsername) { + var message = self.GetConfirmationMessage(packageItem, newSignerUsername); + + if (confirm(message)) { + var url = packageItem.SetRequiredSignerUrl.replace("{username}", newSignerUsername); + + $.ajax({ + method: 'POST', + url: url, + cache: false, + data: window.nuget.addAjaxAntiForgeryToken({}), + complete: function (xhr, textStatus) { + switch (xhr.status) { + case 200: + case 409: + break; + + default: + break; + } + } + }); + + self._requiredSigner(newSignerUsername); + } + } + }); + this.Visible = ko.observable(true); this.UpdateVisibility = function (ownerFilter) { @@ -57,6 +107,67 @@ var url = this.PackagesListViewModel.ManagePackagesViewModel.PackageIconUrlFallback; return "this.src='" + url + "'; this.onerror = null;"; }, this); + + this.GetConfirmationMessage = function (packageItem, newSignerUsername) { + var signerHasCertificate; + var signerIsAny = !newSignerUsername; + var message; + + for (var index in packageItem.AllSigners) { + var signer = packageItem.AllSigners[index]; + + if (signer.Username === newSignerUsername) { + signerHasCertificate = signer.HasCertificate; + break; + } + } + + if (signerIsAny) { + var anySignerWithNoCertificate = false; + var anySignerWithCertificate = false; + + for (var index in packageItem.AllSigners) { + var signer = packageItem.AllSigners[index]; + + if (signer.HasCertificate) { + anySignerWithCertificate = true; + } else { + anySignerWithNoCertificate = true; + } + + if (!signer.Username) { + newSignerUsername = signer.OptionText; + } + } + + message = window.nuget.formatString(strings_RequiredSigner_ThisAction, newSignerUsername) + "\n\n"; + + if (anySignerWithCertificate && anySignerWithNoCertificate) { + message += strings_RequiredSigner_AnyWithMixedResult; + } else if (anySignerWithCertificate) { + message += strings_RequiredSigner_AnyWithSignedResult; + } else { + message += strings_RequiredSigner_AnyWithUnsignedResult; + } + } else { + message = window.nuget.formatString(strings_RequiredSigner_ThisAction, newSignerUsername) + "\n\n"; + + if (signerHasCertificate) { + message += window.nuget.formatString(strings_RequiredSigner_OwnerHasAtLeastOneCertificate, newSignerUsername); + } else { + message += window.nuget.formatString(strings_RequiredSigner_OwnerHasNoCertificate, newSignerUsername); + } + } + + message += "\n\n" + strings_RequiredSigner_Confirm; + + return message; + }; + + this.OnRequiredSignerChange = function (packageItem, event) { + // If the change was cancelled, we need to reset the selected value. + event.currentTarget.value = self._requiredSigner(); + }; } function PackagesListViewModel(managePackagesViewModel, type, packages) { @@ -237,7 +348,7 @@ if (this.Owners.length > 2) { $("#ownerFilter").removeClass("hidden"); } - + this.ListedPackages = new PackagesListViewModel(this, "published", initialData.ListedPackages); this.UnlistedPackages = new PackagesListViewModel(this, "unlisted", initialData.UnlistedPackages); this.ReservedNamespaces = new ReservedNamespaceListViewModel(this, initialData.ReservedNamespaces); diff --git a/src/NuGetGallery/Scripts/gallery/stats-dimensions.js b/src/NuGetGallery/Scripts/gallery/stats-dimensions.js index bd5301e4df..9722cd7b70 100644 --- a/src/NuGetGallery/Scripts/gallery/stats-dimensions.js +++ b/src/NuGetGallery/Scripts/gallery/stats-dimensions.js @@ -1,151 +1,157 @@ -var renderGraph = function (baseUrl, query, clickedId) { - var renderGraphHandler = function (rawData) { - var data = JSON.parse(JSON.stringify(rawData)); - - $("#loading-placeholder").hide(); - if (data != null) { - // Populate the data table - data['reportSize'] = data.Table != null ? data.Table.length : 0; - - data['ShownRows'] = function (allRows) { - var shownRows = []; - var index = 0; - while (shownRows.length < Math.min(6, allRows.length)) { - shownRows.push(allRows[index]); - var currRowSpan = shownRows[index].reduce(function (currMax, nextObj) { - return Math.max(currMax, nextObj != null ? nextObj.Rowspan : 0); - }, 0); - for (var i = 0; i < currRowSpan - 1; i++) { - index++; +var renderGraph = (function () { + 'use strict'; + + var renderGraph = function (baseUrl, query, clickedId) { + var renderGraphHandler = function (rawData) { + var data = JSON.parse(JSON.stringify(rawData)); + + $("#loading-placeholder").hide(); + if (data != null) { + // Populate the data table + data['reportSize'] = data.Table != null ? data.Table.length : 0; + + data['ShownRows'] = function (allRows) { + var shownRows = []; + var index = 0; + while (shownRows.length < Math.min(6, allRows.length)) { shownRows.push(allRows[index]); + var currRowSpan = shownRows[index].reduce(function (currMax, nextObj) { + return Math.max(currMax, nextObj != null ? nextObj.Rowspan : 0); + }, 0); + for (var i = 0; i < currRowSpan - 1; i++) { + index++; + shownRows.push(allRows[index]); + } + + index++; } + return shownRows; + }(data.Table != null ? data.Table : []); - index++; - } - return shownRows; - }(data.Table != null ? data.Table : []); + data['HiddenRows'] = data.Table != null ? data.Table.slice(data['ShownRows'].length) : []; - data['HiddenRows'] = data.Table != null ? data.Table.slice(data['ShownRows'].length) : []; + data['SetupHiddenRows'] = setupHiddenRows.bind(this, data['HiddenRows']); + } - data['SetupHiddenRows'] = setupHiddenRows.bind(this, data['HiddenRows']); - } + $("#report").remove(); - $("#report").remove(); + var reportContainerElement = document.createElement("div"); + $(reportContainerElement).attr("id", "report"); + $(reportContainerElement).attr("data-bind", "template: { name: 'report-template', data: report }"); + $("#report-container").append(reportContainerElement); - var reportContainerElement = document.createElement("div"); - $(reportContainerElement).attr("id", "report"); - $(reportContainerElement).attr("data-bind", "template: { name: 'report-template', data: report }"); - $("#report-container").append(reportContainerElement); + ko.applyBindings({ report: data }, reportContainerElement); + // Render the graph using the data table + packageDisplayGraphs(rawData); - ko.applyBindings({ report: data }, reportContainerElement); - // Render the graph using the data table - packageDisplayGraphs(rawData); + window.nuget.configureExpander( + "hidden-rows", + "CalculatorAddition", + "Show less", + "CalculatorSubtract", + "Show more"); - window.nuget.configureExpander( - "hidden-rows", - "CalculatorAddition", - "Show less", - "CalculatorSubtract", - "Show more"); - - // Add the click handler to the checkboxes - groupbyNavigation(baseUrl); + // Add the click handler to the checkboxes + groupbyNavigation(baseUrl); - // Set the focus to the checkbox that initiated this request - if (clickedId) { - $('#' + clickedId).focus(); - } - }; + // Set the focus to the checkbox that initiated this request + if (clickedId) { + $('#' + clickedId).focus(); + } + }; - $.ajax({ - url: baseUrl + query, - type: 'GET', - dataType: 'json', - success: renderGraphHandler, - error: function () { - renderGraphHandler(null); - $("#loading-placeholder").hide(); + $.ajax({ + url: baseUrl + query, + type: 'GET', + dataType: 'json', + success: renderGraphHandler, + error: function () { + renderGraphHandler(null); + $("#loading-placeholder").hide(); - $('#statistics-retry').click(function () { - renderGraph(baseUrl, query); - }); - } - }); -} - -var groupbyNavigation = function (baseUrl) { - $('.dimension-checkbox').click(function (event) { - var container = $("#stats-data-display").parent(); - $("#stats-data-display").remove(); - $("#loading-placeholder").show(); - var clickedId = event.target.id; - - var query = ''; - $('.dimension-checkbox').each(function (index, element) { - if (element.checked) { - if (query) { - query += '&'; - } else { - query = '?'; - } - query += 'groupby=' + element.value; + $('#statistics-retry').click(function () { + renderGraph(baseUrl, query); + }); } }); + }; - history.replaceState({}, "", query); - renderGraph(baseUrl, query, clickedId); - }); -} + var groupbyNavigation = function (baseUrl) { + $('.dimension-checkbox').click(function (event) { + var container = $("#stats-data-display").parent(); + $("#stats-data-display").remove(); + $("#loading-placeholder").show(); + var clickedId = event.target.id; + + var query = ''; + $('.dimension-checkbox').each(function (index, element) { + if (element.checked) { + if (query) { + query += '&'; + } else { + query = '?'; + } + query += 'groupby=' + element.value; + } + }); -var setupHiddenRows = function (data) { - var container = $("#hidden-rows"); - // no-op if we've already appended the hidden rows - if (container.children().length > 0) { - return; + history.replaceState({}, "", query); + renderGraph(baseUrl, query, clickedId); + }); } - var tableContainer = container.parent(); - container.remove(); - - var trArr = []; - for (var i = 0; i < data.length; i++) { - var tempTr = $(document.createElement("tr")); - var tdArr = []; - for (var j = 0; j < data[i].length; j++) { - var tempTd = $(document.createElement("td")); - var item = data[i][j]; - if (item != null) { - tempTd.attr("class", item.IsNumeric ? "statistics-number" : ""); - tempTd.attr("rowspan", item.Rowspan > 0 ? item.Rowspan : ""); - var content = null; - if (item.Uri != null) { - content = $(document.createElement("a")); - content.attr("href", item.Uri); - content.text(item.Data); - } else { - var textValue = item.IsNumeric ? parseInt(item.Data).toLocaleString() : item.Data; - content = $(document.createElement("span")); - content.attr("aria-label", textValue); - content.text(textValue); - } + var setupHiddenRows = function (data) { + var container = $("#hidden-rows"); + // no-op if we've already appended the hidden rows + if (container.children().length > 0) { + return; + } + + var tableContainer = container.parent(); + container.remove(); + + var trArr = []; + for (var i = 0; i < data.length; i++) { + var tempTr = $(document.createElement("tr")); + var tdArr = []; + for (var j = 0; j < data[i].length; j++) { + var tempTd = $(document.createElement("td")); + var item = data[i][j]; + if (item != null) { + tempTd.attr("class", item.IsNumeric ? "statistics-number" : ""); + tempTd.attr("rowspan", item.Rowspan > 0 ? item.Rowspan : ""); + var content = null; + if (item.Uri != null) { + content = $(document.createElement("a")); + content.attr("href", item.Uri); + content.text(item.Data); + } else { + var textValue = item.IsNumeric ? parseInt(item.Data).toLocaleString() : item.Data; + content = $(document.createElement("span")); + content.attr("aria-label", textValue); + content.text(textValue); + } - tempTd.append(content); - tdArr.push(tempTd); + tempTd.append(content); + tdArr.push(tempTd); + } } + tempTr.append(tdArr); + trArr.push(tempTr); } - tempTr.append(tdArr); - trArr.push(tempTr); + + container.append(trArr); + tableContainer.append(container); + + // When we remove the 'hidden-rows' element from the container above, we apparently kill all the event handlers on it. So reattach here. + window.nuget.configureExpander( + "hidden-rows", + "CalculatorAddition", + "Show less", + "CalculatorSubtract", + "Show more"); } - container.append(trArr); - tableContainer.append(container); - - // When we remove the 'hidden-rows' element from the container above, we apparently kill all the event handlers on it. So reattach here. - window.nuget.configureExpander( - "hidden-rows", - "CalculatorAddition", - "Show less", - "CalculatorSubtract", - "Show more"); -} \ No newline at end of file + return renderGraph; +}()); diff --git a/src/NuGetGallery/Scripts/gallery/stats-perpackagestatsgraphs.js b/src/NuGetGallery/Scripts/gallery/stats-perpackagestatsgraphs.js index 73c7e4202f..44f3cd7c3d 100644 --- a/src/NuGetGallery/Scripts/gallery/stats-perpackagestatsgraphs.js +++ b/src/NuGetGallery/Scripts/gallery/stats-perpackagestatsgraphs.js @@ -1,291 +1,295 @@ - -var graphData; -// This number is from trial and error and seeing what fit in the space -var axisLabelCharLimit = 19; - -var packageDisplayGraphs = function (data) { - window.graphData = data; - $("#stats-graph-svg").remove(); - switch (data.Id) { - case 'report-Version': - drawDownloadsByVersionBarChart(data); - break; - case 'report-ClientName': - drawDownloadsByClientNameBarChart(data); - break; - default: - break; - } -} - -var drawDownloadsByVersionBarChart = function (rawData) { - - // scrape data if we don't get a model - var data = GetChartData(rawData, function (item) { return false; }); - - if (data.length <= 0) { - d3.selectAll('#report-Version .statistics-data tbody tr').each(function () { - var item = { - label: d3.select(this).select(':nth-child(1)').text().replace(/(^\s*)|(\s*$)/g, ''), - downloads: +(d3.select(this).select(':nth-child(2)').text().replace(/[^0-9]+/g, '')) - }; - data[data.length] = item; - }); - } - - // we get descending order from server. Reverse so we can cut the right versions. - data.reverse(); - - if (data.length < 1) { - return; - } - - // limit the bar graph to the most recent 15 versions - if (data.length > 15) { - data = data.slice(data.length - 15, data.length); - } - - // draw graph - var reportGraphWidth = $('#statistics-graph-id').width(); - - reportGraphWidth = Math.min(reportGraphWidth, 1170); - - var margin = { top: 40, right: 10, bottom: 130, left: 45 }, - width = reportGraphWidth - margin.left - margin.right, - height = 450 - margin.top - margin.bottom; - - var xScale = d3.scale.ordinal() - .rangeRoundBands([10, width], .1); - - var yScale = d3.scale.linear() - .range([height, 0]); - - var xAxis = d3.svg.axis() - .scale(xScale) - .orient('bottom') - .tickFormat(function (d) { - return d.substring(0, axisLabelCharLimit) + (d.length > axisLabelCharLimit? "..." :""); - }); - - var yAxis = d3.svg.axis() - .scale(yScale) - .orient('left') - .tickFormat(function (d) { - return GetShortNumberString(d); - }); - - var svg = d3.select('#statistics-graph-id') - .append('svg') - .attr('id', 'stats-graph-svg') - .attr('width', width + margin.left + margin.right) - .attr('height', height + margin.top + margin.bottom); - - svg.append('title').text('Downloads By Version'); - svg.append('desc').text('This is a graph showing the number of downloads of this Package broken out by version.'); - - svg = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); - - xScale.domain(data.map(function (d) { return d.label; })); - yScale.domain([0, d3.max(data, function (d) { return d.downloads; })]); - - // the use of dx attribute on the text element is correct, however, the negative shift doesn't appear to work on Firefox - // the workaround employed here is to add a translation to the rotation transform - - svg.append("g") - .attr("class", "x axis long") - .attr("transform", "translate(0," + height + ")") - .call(xAxis) - .selectAll("text") - .style("text-anchor", "end") - //.attr("dx", "-.8em") - .attr("dy", ".15em") - .attr("transform", function (d) { - return "rotate(-65),translate(-10,0)"; - }); - - svg.selectAll(".bar") - .data(data) - .enter() - .append("rect") - .attr("class", "bar") - .attr("x", function (d) { return xScale(d.label); }) - .attr("width", xScale.rangeBand()) - .attr("y", function (d) { return yScale(d.downloads); }) - .attr("height", function (d) { return height - yScale(d.downloads); }) - .append("title").text(function (d) { return d.downloads + " Downloads"; }); - - svg.append("foreignObject") - .attr("x", "1.71em") - .attr("y", -10) - .attr("width", width - 20 + "px") - .attr("height", "2em") - .attr("font-weight", "bold") - .append("xhtml:body") - .append("p") - .attr("style", "text-align:center") - .text("Downloads for 15 Latest Package Versions (Last 6 weeks)"); - - svg.append("g") - .attr("class", "y axis") - .call(yAxis) - .append("text") - .attr("transform", "rotate(-90)") - .attr("y", 6) - .attr("dy", ".71em") - .style("text-anchor", "end") - .text("Downloads"); -} - -var drawDownloadsByClientNameBarChart = function (rawData) { - - // scrape data - - var data = GetChartData(rawData, function (item) { - return item.label === '(unknown)'; - }); - - if (data.length <= 0) { - d3.selectAll('#report-ClientName .statistics-data tbody tr').each(function () { - var item = { - label: d3.select(this).select(':nth-child(1)').text().replace(/(^\s*)|(\s*$)/g, ''), - downloads: +(d3.select(this).select(':nth-child(2)').text().replace(/[^0-9]+/g, '')) - }; - - // filter out unknown - if (item.label !== '(unknown)') { +var packageDisplayGraphs = (function () { + 'use strict'; + + var graphData; + // This number is from trial and error and seeing what fit in the space + var axisLabelCharLimit = 19; + + var packageDisplayGraphs = function (data) { + window.graphData = data; + $("#stats-graph-svg").remove(); + switch (data.Id) { + case 'report-Version': + drawDownloadsByVersionBarChart(data); + break; + case 'report-ClientName': + drawDownloadsByClientNameBarChart(data); + break; + default: + break; + } + }; + + var drawDownloadsByVersionBarChart = function (rawData) { + + // scrape data if we don't get a model + var data = GetChartData(rawData, function (item) { return false; }); + + if (data.length <= 0) { + d3.selectAll('#report-Version .statistics-data tbody tr').each(function () { + var item = { + label: d3.select(this).select(':nth-child(1)').text().replace(/(^\s*)|(\s*$)/g, ''), + downloads: +(d3.select(this).select(':nth-child(2)').text().replace(/[^0-9]+/g, '')) + }; data[data.length] = item; - } - }); - } - - data.reverse(); - - if (data.length < 1) { - return; + }); + } + + // we get descending order from server. Reverse so we can cut the right versions. + data.reverse(); + + if (data.length < 1) { + return; + } + + // limit the bar graph to the most recent 15 versions + if (data.length > 15) { + data = data.slice(data.length - 15, data.length); + } + + // draw graph + var reportGraphWidth = $('#statistics-graph-id').width(); + + reportGraphWidth = Math.min(reportGraphWidth, 1170); + + var margin = { top: 40, right: 10, bottom: 130, left: 45 }, + width = reportGraphWidth - margin.left - margin.right, + height = 450 - margin.top - margin.bottom; + + var xScale = d3.scale.ordinal() + .rangeRoundBands([10, width], .1); + + var yScale = d3.scale.linear() + .range([height, 0]); + + var xAxis = d3.svg.axis() + .scale(xScale) + .orient('bottom') + .tickFormat(function (d) { + return d.substring(0, axisLabelCharLimit) + (d.length > axisLabelCharLimit ? "..." : ""); + }); + + var yAxis = d3.svg.axis() + .scale(yScale) + .orient('left') + .tickFormat(function (d) { + return GetShortNumberString(d); + }); + + var svg = d3.select('#statistics-graph-id') + .append('svg') + .attr('id', 'stats-graph-svg') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom); + + svg.append('title').text('Downloads By Version'); + svg.append('desc').text('This is a graph showing the number of downloads of this Package broken out by version.'); + + svg = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + xScale.domain(data.map(function (d) { return d.label; })); + yScale.domain([0, d3.max(data, function (d) { return d.downloads; })]); + + // the use of dx attribute on the text element is correct, however, the negative shift doesn't appear to work on Firefox + // the workaround employed here is to add a translation to the rotation transform + + svg.append("g") + .attr("class", "x axis long") + .attr("transform", "translate(0," + height + ")") + .call(xAxis) + .selectAll("text") + .style("text-anchor", "end") + //.attr("dx", "-.8em") + .attr("dy", ".15em") + .attr("transform", function (d) { + return "rotate(-65),translate(-10,0)"; + }); + + svg.selectAll(".bar") + .data(data) + .enter() + .append("rect") + .attr("class", "bar") + .attr("x", function (d) { return xScale(d.label); }) + .attr("width", xScale.rangeBand()) + .attr("y", function (d) { return yScale(d.downloads); }) + .attr("height", function (d) { return height - yScale(d.downloads); }) + .append("title").text(function (d) { return d.downloads + " Downloads"; }); + + svg.append("foreignObject") + .attr("x", "1.71em") + .attr("y", -10) + .attr("width", width - 20 + "px") + .attr("height", "2em") + .attr("font-weight", "bold") + .append("xhtml:body") + .append("p") + .attr("style", "text-align:center") + .text("Downloads for 15 Latest Package Versions (Last 6 weeks)"); + + svg.append("g") + .attr("class", "y axis") + .call(yAxis) + .append("text") + .attr("transform", "rotate(-90)") + .attr("y", 6) + .attr("dy", ".71em") + .style("text-anchor", "end") + .text("Downloads"); } - // draw graph + var drawDownloadsByClientNameBarChart = function (rawData) { - var reportGraphWidth = $('#statistics-graph-id').width(); - reportGraphWidth = Math.min(reportGraphWidth, 1170); + // scrape data - var margin = { top: 40, right: 10, bottom: 50, left: 150 }, - width = reportGraphWidth - margin.left - margin.right, - height = Math.max(550, data.length * 25) - margin.top - margin.bottom; - - var xScale = d3.scale.linear() - .range([0, width - 50]); - var yScale = d3.scale.ordinal() - .rangeRoundBands([height, 20], .1); - - var xAxis = d3.svg.axis() - .scale(xScale) - .orient('bottom') - .tickFormat(function (d) { - return GetShortNumberString(d); + var data = GetChartData(rawData, function (item) { + return item.label === '(unknown)'; }); - var yAxis = d3.svg.axis() - .scale(yScale) - .orient('left') - .tickFormat(function (d) { - return d.substring(0, axisLabelCharLimit) + (d.length > axisLabelCharLimit ? "..." : ""); - }); - - var svg = d3.select('#statistics-graph-id') - .append('svg') - .attr('id', 'stats-graph-svg') - .attr('width', width + margin.left + margin.right) - .attr('height', height + margin.top + margin.bottom); - - svg.append('title').text('Downloads By Client'); - svg.append('desc').text('This is a graph showing the number of downloads of this Package broken out by client.'); - - svg = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); - - xScale.domain([0, d3.max(data, function (d) { return d.downloads; })]); - yScale.domain(data.map(function (d) { return d.label; })); - - // the use of dx attribute on the text element is correct, however, the negative shift doesn't appear to work on Firefox - // the workaround employed here is to add a translation to the rotation transform - - svg.append("g") - .attr("class", "x axis") - .attr("transform", "translate(0," + height + ")") - .call(xAxis); - - svg.selectAll(".bar") - .data(data) - .enter() - .append("rect") - .attr("class", "bar") - .attr("x", 0) - .attr("width", function (d) { return xScale(d.downloads); }) - .attr("y", function (d) { return yScale(d.label); }) - .attr("height", yScale.rangeBand()) - .append("title").text(function (d) { return d.downloads.toLocaleString() + " Downloads"; }); - - svg.append("foreignObject") - .attr("x", 0) - .attr("y", -10) - .attr("width", width + "px") - .attr("height", "2em") - .attr("font-weight", "bold") - .append("xhtml:body") - .append("p") - .attr("style", "text-align:center") - .text("Downloads by Client (Last 6 weeks)"); - - svg.append("g") - .attr("class", "y axis long") - .call(yAxis); -} - -var GetChartData = function (rawData, filter) { - var data = []; - - if (rawData.Table && rawData.Table.length > 0) { - rawData.Table.forEach(function (dataPoint) { - var item = { - label: dataPoint[0].Data, - downloads: window.nuget.parseNumber(dataPoint[1].Data) - }; - - if (!filter(item)) { - data[data.length] = item; - } - }); + if (data.length <= 0) { + d3.selectAll('#report-ClientName .statistics-data tbody tr').each(function () { + var item = { + label: d3.select(this).select(':nth-child(1)').text().replace(/(^\s*)|(\s*$)/g, ''), + downloads: +(d3.select(this).select(':nth-child(2)').text().replace(/[^0-9]+/g, '')) + }; + + // filter out unknown + if (item.label !== '(unknown)') { + data[data.length] = item; + } + }); + } + + data.reverse(); + + if (data.length < 1) { + return; + } + + // draw graph + + var reportGraphWidth = $('#statistics-graph-id').width(); + reportGraphWidth = Math.min(reportGraphWidth, 1170); + + var margin = { top: 40, right: 10, bottom: 50, left: 150 }, + width = reportGraphWidth - margin.left - margin.right, + height = Math.max(550, data.length * 25) - margin.top - margin.bottom; + + var xScale = d3.scale.linear() + .range([0, width - 50]); + var yScale = d3.scale.ordinal() + .rangeRoundBands([height, 20], .1); + + var xAxis = d3.svg.axis() + .scale(xScale) + .orient('bottom') + .tickFormat(function (d) { + return GetShortNumberString(d); + }); + + var yAxis = d3.svg.axis() + .scale(yScale) + .orient('left') + .tickFormat(function (d) { + return d.substring(0, axisLabelCharLimit) + (d.length > axisLabelCharLimit ? "..." : ""); + }); + + var svg = d3.select('#statistics-graph-id') + .append('svg') + .attr('id', 'stats-graph-svg') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom); + + svg.append('title').text('Downloads By Client'); + svg.append('desc').text('This is a graph showing the number of downloads of this Package broken out by client.'); + + svg = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + xScale.domain([0, d3.max(data, function (d) { return d.downloads; })]); + yScale.domain(data.map(function (d) { return d.label; })); + + // the use of dx attribute on the text element is correct, however, the negative shift doesn't appear to work on Firefox + // the workaround employed here is to add a translation to the rotation transform + + svg.append("g") + .attr("class", "x axis") + .attr("transform", "translate(0," + height + ")") + .call(xAxis); + + svg.selectAll(".bar") + .data(data) + .enter() + .append("rect") + .attr("class", "bar") + .attr("x", 0) + .attr("width", function (d) { return xScale(d.downloads); }) + .attr("y", function (d) { return yScale(d.label); }) + .attr("height", yScale.rangeBand()) + .append("title").text(function (d) { return d.downloads.toLocaleString() + " Downloads"; }); + + svg.append("foreignObject") + .attr("x", 0) + .attr("y", -10) + .attr("width", width + "px") + .attr("height", "2em") + .attr("font-weight", "bold") + .append("xhtml:body") + .append("p") + .attr("style", "text-align:center") + .text("Downloads by Client (Last 6 weeks)"); + + svg.append("g") + .attr("class", "y axis long") + .call(yAxis); } - return data; -} + var GetChartData = function (rawData, filter) { + var data = []; -var GetShortNumberString = function (number) { - if (number == 0) { - return "0"; - } + if (rawData.Table && rawData.Table.length > 0) { + rawData.Table.forEach(function (dataPoint) { + var item = { + label: dataPoint[0].Data, + downloads: window.nuget.parseNumber(dataPoint[1].Data) + }; - var abbreviation = ["", "k", "M", "B", "T", "q", "Q", "s", "S", "o", "n"]; - var numDiv = 0; - while (number >= 1000) { - number = number / 1000.0; - numDiv++; - } + if (!filter(item)) { + data[data.length] = item; + } + }); + } - rounded = Math.floor(number); - if (rounded >= 100) { - number = number.toPrecision(3); - } else { - number = number.toPrecision(2); + return data; } - if (numDiv >= abbreviation.Length) { - return number + "10^" + numDiv*3; + var GetShortNumberString = function (number) { + if (number == 0) { + return "0"; + } + + var abbreviation = ["", "k", "M", "B", "T", "q", "Q", "s", "S", "o", "n"]; + var numDiv = 0; + while (number >= 1000) { + number = number / 1000.0; + numDiv++; + } + + var rounded = Math.floor(number); + if (rounded >= 100) { + number = number.toPrecision(3); + } else { + number = number.toPrecision(2); + } + + if (numDiv >= abbreviation.Length) { + return number + "10^" + numDiv * 3; + } + return number + abbreviation[numDiv]; } - return number + abbreviation[numDiv]; -} + $(window).resize(function () { + packageDisplayGraphs(graphData); + }); -$(window).resize(function () { - packageDisplayGraphs(graphData); -}); \ No newline at end of file + return packageDisplayGraphs; +}()); diff --git a/src/NuGetGallery/Scripts/nugetgallery.js b/src/NuGetGallery/Scripts/nugetgallery.js index 8560a05709..c703aaca4f 100644 --- a/src/NuGetGallery/Scripts/nugetgallery.js +++ b/src/NuGetGallery/Scripts/nugetgallery.js @@ -1,14 +1,20 @@ // Global utility script for NuGetGallery /// -// Shared function for adding an anti-forgery token defined by ViewHelpers.AjaxAntiForgeryToken to an ajax request -function addAjaxAntiForgeryToken(data) { - var $field = $("#AntiForgeryForm input[name=__RequestVerificationToken]"); - data["__RequestVerificationToken"] = $field.val(); - return data; -} +var addAjaxAntiForgeryToken = (function () { + 'use strict'; + + // Shared function for adding an anti-forgery token defined by ViewHelpers.AjaxAntiForgeryToken to an ajax request + return function (data) { + var $field = $("#AntiForgeryForm input[name=__RequestVerificationToken]"); + data["__RequestVerificationToken"] = $field.val(); + return data; + }; +}()); (function (window, $, undefined) { + 'use strict'; + $(function () { // Export an object with global config data var app = $(document.documentElement).data(); diff --git a/src/NuGetGallery/Scripts/stats.js b/src/NuGetGallery/Scripts/stats.js deleted file mode 100644 index 7b206d0d56..0000000000 --- a/src/NuGetGallery/Scripts/stats.js +++ /dev/null @@ -1,66 +0,0 @@ -function getStats(currData) { - currData = currData || {}; - - $.get(window.app.root + 'stats/totals', function (data) { - var section = $('section.aggstats'); - section.show(); - update(data, currData, 'UniquePackages'); - update(data, currData, 'Downloads'); - update(data, currData, 'TotalPackages'); - }).error(function () { - // Don't show the stats error anymore. Just fail silently. - // var section = $('section.aggstatserr'); - // section.show(); - }); -} - -function update(data, currData, key) { - var currentValue = currData[key] || ''; - var value = data[key].toString(); - var self = $('#' + key); - - if (currentValue != value) { - currData[key] = value; - var length = value.length; - var currLength = currentValue.length; - var items = self.children('span'); - - if (currLength > length) { - items.slice(0, currLength - length).remove(); - items = items.slice(currLength - length); - } - - if (currLength) { - // Do not animate the first time around. - $.each(value.split('').reverse(), function (i, e) { - var c = (i <= currLength) ? currentValue.charAt(currLength - i - 1) : ''; - if (c != e) { - var el = $(items[length - i - 1]); - animateEl(el, e); - } - }); - } - if (currLength < length) { - var i; - for (i = currLength; i < length; i++) { - self.prepend('' + value.charAt(length - i - 1) + ''); - } - items = self.children('span'); - } - } -} - -function animateEl(el, v) { - v = v || ''; - var parent = el.parent(); - el.stop(true, true).animate({ top: 0.3 * parseInt(parent.height()) }, 350, 'linear', function () { - $(this).html(v).css({ top: -0.8 * parseInt(parent.height()) }).animate({ top: 0 }, 350, 'linear'); - }); -} - -$(document).ready(function () { - var elem = document.getElementsByClassName("aggstats"); - if (elem != null && elem.length > 0) { - getStats(); - } -}); diff --git a/src/NuGetGallery/Scripts/statsgraphs.js b/src/NuGetGallery/Scripts/statsgraphs.js deleted file mode 100644 index 719210ddd9..0000000000 --- a/src/NuGetGallery/Scripts/statsgraphs.js +++ /dev/null @@ -1,165 +0,0 @@ -var drawNugetClientVersionBarChart = function () { - - var margin = { top: 20, right: 30, bottom: 80, left: 80 }, - width = 460 - margin.left - margin.right, - height = 320 - margin.top - margin.bottom; - - var xScale = d3.scale.ordinal() - .rangeRoundBands([0, width], .1); - - var yScale = d3.scale.linear() - .range([height, 0]); - - var xAxis = d3.svg.axis() - .scale(xScale) - .orient('bottom'); - - var yAxis = d3.svg.axis() - .scale(yScale) - .orient('left'); - - var svg = d3.select('#downloads-by-nuget-version').append('svg') - .attr('width', width + margin.left + margin.right) - .attr('height', height + margin.top + margin.bottom); - - svg.append('title').text('NuGet Client Usage (Last 6 Weeks)'); - svg.append('desc').text('This is a graph showing the number of downloads by each version of the NuGet client over the last six weeks.'); - - svg = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); - - var data = []; - - d3.selectAll('#downloads-by-nuget-version tbody tr').each(function () { - var item = { - nugetVersion: d3.select(this).select(':nth-child(1)').text(), - downloads: +(d3.select(this).select(':nth-child(2)').text().replace(/[^0-9]+/g, '')), - percentage: d3.select(this).select(':nth-child(3)').text(), - }; - data[data.length] = item; - }); - - xScale.domain(data.map(function (d) { return d.nugetVersion; })); - yScale.domain([0, d3.max(data, function (d) { return d.downloads; })]); - - // the use of dx attribute on the text element is correct, however, the negative shift doesn't appear to work on Firefox - // the workaround employed here is to add a translation to the rotation transform - - svg.append("g") - .attr("class", "x axis") - .attr("transform", "translate(0," + height + ")") - .call(xAxis) - .selectAll("text") - .style("text-anchor", "end") - //.attr("dx", "-.8em") - .attr("dy", ".15em") - .attr("transform", function (d) { - return "rotate(-65),translate(-10,0)" - }); - - svg.append("g") - .attr("class", "y axis") - .call(yAxis) - .append("text") - .attr("transform", "rotate(-90)") - .attr("y", 6) - .attr("dy", ".71em") - .style("text-anchor", "end") - .text("Downloads"); - - svg.selectAll(".bar") - .data(data) - .enter() - .append("rect") - .attr("class", "bar") - .attr("x", function (d) { return xScale(d.nugetVersion); }) - .attr("width", xScale.rangeBand()) - .attr("y", function (d) { return yScale(d.downloads); }) - .attr("height", function (d) { return height - yScale(d.downloads); }) - .append("title") - .text(function (d) { return d.percentage; }); -} - -var drawMonthlyDownloadsLineChart = function () { - - var margin = { top: 20, right: 20, bottom: 80, left: 80 }, - width = 400 - margin.left - margin.right, - height = 300 - margin.top - margin.bottom; - - var xScale = d3.scale.ordinal() - .rangePoints([0, width]); - - var yScale = d3.scale.linear() - .range([height, 0]); - - var xAxis = d3.svg.axis() - .scale(xScale) - .orient('bottom'); - - var yAxis = d3.svg.axis() - .scale(yScale) - .orient('left'); - - var data = []; - - d3.selectAll('#downloads-per-month tbody tr').each(function () { - var item = { - month: d3.select(this).select(':nth-child(1)').text(), - downloads: +(d3.select(this).select(':nth-child(2)').text().replace(/[^0-9]+/g, '')) - }; - data[data.length] = item; - }); - - var line = d3.svg.line() - .x(function (d) { return xScale(d.month); }) - .y(function (d) { return yScale(d.downloads); }); - - var svg = d3.select("#downloads-per-month").append("svg") - .attr("width", width + margin.left + margin.right) - .attr("height", height + margin.top + margin.bottom); - - svg.append('title').text('Packages Downloaded - Month to Date'); - svg.append('desc').text('This is a graph showing the number of downloads from NuGet per month.'); - - svg = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"); - - xScale.domain(data.map(function (d) { return d.month; })); - yScale.domain([0, d3.max(data, function (d) { return d.downloads; })]); - - // the use of dx attribute on the text element is correct, however, the negative shift doesn't appear to work on Firefox - // the workaround employed here is to add a translation to the rotation transform - - svg.append("g") - .attr("class", "x axis") - .attr("transform", "translate(0," + height + ")") - .call(xAxis) - .selectAll("text") - .style("text-anchor", "end") - //.attr("dx", "-.8em") - .attr("dy", ".15em") - .attr("transform", function (d) { - return "rotate(-65),translate(-10,0)" - }); - - svg.append("g") - .attr("class", "y axis") - .call(yAxis); - - svg.append("path") - .datum(data) - .attr("class", "line") - .attr("d", line); - - var formatDownloads = d3.format(','); - - svg.selectAll('.point') - .data(data) - .enter() - .append("svg:circle") - .attr("class", "line-graph-dot") - .attr("cx", function (d) { return xScale(d.month); }) - .attr("cy", function (d) { return yScale(d.downloads); }) - .attr("r", 5) - .append("title") - .text(function (d) { return formatDownloads(d.downloads); }); -} - diff --git a/src/NuGetGallery/Scripts/supportrequests.js b/src/NuGetGallery/Scripts/supportrequests.js index daa4571150..2c48b21bda 100644 --- a/src/NuGetGallery/Scripts/supportrequests.js +++ b/src/NuGetGallery/Scripts/supportrequests.js @@ -1,277 +1,294 @@ -function HistoryViewModel() { - var $self = this; - - this.issue = ko.observable(); - this.historyEntries = ko.observableArray(); -}; - -function EditViewModel(editUrl) { - var $self = this; - - this.issue = ko.observable(); - this.editAssignedToId = ko.observable(); - this.editIssueStatusId = ko.observable(); - this.editIssueComment = ko.observable(); - this.assignedToChoices = ko.observableArray(); - this.issueStatusChoices = ko.observableArray(); - - this.updateSupportRequest = function (success, error) { - var model = { - issueKey: $self.issue.Key, - assignedToId: $self.editAssignedToId, - issueStatusId: $self.editIssueStatusId, - comment: $self.editIssueComment() - }; - - $.ajax({ - url: editUrl, - type: 'POST', - cache: false, - dataType: 'json', - data: addAjaxAntiForgeryToken(model), - success: success - }) - .error(error); - } -} - -function SupportRequestsViewModel(editUrl, filterUrl, historyUrl) { - var $self = this; - - this.editUrl = editUrl; - this.filterUrl = filterUrl; - this.historyUrl = historyUrl; - this.editSupportRequestForm = $('#editSupportRequest-form').get(0); - this.editAssignedToCtrl = $('#editAssignedTo').get(0); - this.editIssueStatusCtrl = $('#editIssueStatus').get(0); - this.editIssueCommentCtrl = $('#editIssueComment').get(0); - this.historyTableCtrl = $('#history-table').get(0); - - this.assignedToFilter = ko.observable(); - this.issueStatusIdFilter = ko.observable(); - this.reasonFilter = ko.observable(); - this.pageNumber = ko.observable(1); - this.maxPageNumber = ko.observable(1); - this.take = ko.observable(30); - - this.hasPreviousPage = ko.computed(function () { - return $self.pageNumber() > 1; - }); +var HistoryViewModel = (function () { + 'use strict'; - this.hasNextPage = ko.computed(function () { - return $self.pageNumber() < $self.maxPageNumber(); - }); + return function () { + var $self = this; - this.goToPreviousPage = function () { - $self.filter($self.pageNumber() - 1, $self.take()); + this.issue = ko.observable(); + this.historyEntries = ko.observableArray(); }; - this.goToNextPage = function () { - $self.filter($self.pageNumber() + 1, $self.take()); +}()); + +var EditViewModel = (function () { + 'use strict'; + + return function (editUrl) { + var $self = this; + + this.issue = ko.observable(); + this.editAssignedToId = ko.observable(); + this.editIssueStatusId = ko.observable(); + this.editIssueComment = ko.observable(); + this.assignedToChoices = ko.observableArray(); + this.issueStatusChoices = ko.observableArray(); + + this.updateSupportRequest = function (success, error) { + var model = { + issueKey: $self.issue.Key, + assignedToId: $self.editAssignedToId, + issueStatusId: $self.editIssueStatusId, + comment: $self.editIssueComment() + }; + + $.ajax({ + url: editUrl, + type: 'POST', + cache: false, + dataType: 'json', + data: addAjaxAntiForgeryToken(model), + success: success + }) + .error(error); + } }; - - this.filteredIssues = ko.observableArray(); - this.assignedToChoices = ko.observableArray(); - this.issueStatusChoices = ko.observableArray(); - this.reasonChoices = ko.observableArray(); - - this.styleButtons = function () { - $('a.editButton').button( - { - icons: { - primary: 'ui-icon-pencil' - } +}()); + +var SupportRequestsViewModel = (function () { + 'use strict'; + + return function (editUrl, filterUrl, historyUrl) { + var $self = this; + + this.editUrl = editUrl; + this.filterUrl = filterUrl; + this.historyUrl = historyUrl; + this.editSupportRequestForm = $('#editSupportRequest-form').get(0); + this.editAssignedToCtrl = $('#editAssignedTo').get(0); + this.editIssueStatusCtrl = $('#editIssueStatus').get(0); + this.editIssueCommentCtrl = $('#editIssueComment').get(0); + this.historyTableCtrl = $('#history-table').get(0); + + this.assignedToFilter = ko.observable(); + this.issueStatusIdFilter = ko.observable(); + this.reasonFilter = ko.observable(); + this.pageNumber = ko.observable(1); + this.maxPageNumber = ko.observable(1); + this.take = ko.observable(30); + + this.hasPreviousPage = ko.computed(function () { + return $self.pageNumber() > 1; }); - $('a.historyButton').button( - { - icons: { - primary: 'ui-icon-clock' - } - }); - $('a.contactButton').button( - { - icons: { - primary: 'ui-icon-mail-closed' - } + + this.hasNextPage = ko.computed(function () { + return $self.pageNumber() < $self.maxPageNumber(); }); - } - this.updateSupportRequest = function () { - var updatedViewModel = ko.dataFor($self.editSupportRequestForm); + this.goToPreviousPage = function () { + $self.filter($self.pageNumber() - 1, $self.take()); + }; + this.goToNextPage = function () { + $self.filter($self.pageNumber() + 1, $self.take()); + }; - updatedViewModel.updateSupportRequest( - function () { - $self.pageNumber(0); - $self.filter(); - $self.editSupportRequestDialog.dialog("close"); - }, - function (jqXhr, textStatus, errorThrown) { - alert("Error: " + errorThrown); - }); - } - - this.historyDialog = $('#history-dialog').dialog({ - autoOpen: false, - modal: true, - width: 800, - overlay: { - backgroundColor: '#000', - opacity: 0.5 - }, - buttons: { - "Close": function () { - $self.historyDialog.dialog("close"); - } + this.filteredIssues = ko.observableArray(); + this.assignedToChoices = ko.observableArray(); + this.issueStatusChoices = ko.observableArray(); + this.reasonChoices = ko.observableArray(); + + this.styleButtons = function () { + $('a.editButton').button( + { + icons: { + primary: 'ui-icon-pencil' + } + }); + $('a.historyButton').button( + { + icons: { + primary: 'ui-icon-clock' + } + }); + $('a.contactButton').button( + { + icons: { + primary: 'ui-icon-mail-closed' + } + }); } - }); - this.editSupportRequestFields = $([]) - .add($self.editAssignedToCtrl) - .add($self.editIssueStatusCtrl) - .add($self.editIssueCommentCtrl); - - this.editSupportRequestDialog = $("#editSupportRequest-dialog").dialog({ - autoOpen: false, - modal: true, - width: 400, - overlay: { - backgroundColor: '#000', - opacity: 0.5 - }, - buttons: { - "Save Changes": $self.updateSupportRequest, - Cancel: function () { - $self.editSupportRequestDialog.dialog("close"); - } - }, - close: function () { - $('#editSupportRequest-form')[0].reset(); - $self.editSupportRequestFields.removeClass("ui-state-error"); + this.updateSupportRequest = function () { + var updatedViewModel = ko.dataFor($self.editSupportRequestForm); + + updatedViewModel.updateSupportRequest( + function () { + $self.pageNumber(0); + $self.filter(); + $self.editSupportRequestDialog.dialog("close"); + }, + function (jqXhr, textStatus, errorThrown) { + alert("Error: " + errorThrown); + }); } - }); - this.editSupportRequest = function (supportRequestViewModel) { - - var editViewModel = new EditViewModel($self.editUrl); - editViewModel.issue = supportRequestViewModel; - editViewModel.assignedToChoices = $self.assignedToChoices; - editViewModel.issueStatusChoices = $self.issueStatusChoices.filter(function (value) { - return value.Text !== 'Unresolved'; + this.historyDialog = $('#history-dialog').dialog({ + autoOpen: false, + modal: true, + width: 800, + overlay: { + backgroundColor: '#000', + opacity: 0.5 + }, + buttons: { + "Close": function () { + $self.historyDialog.dialog("close"); + } + } }); - editViewModel.editAssignedToId = supportRequestViewModel.AssignedTo; - editViewModel.editIssueStatusId = supportRequestViewModel.IssueStatusId; + this.editSupportRequestFields = $([]) + .add($self.editAssignedToCtrl) + .add($self.editIssueStatusCtrl) + .add($self.editIssueCommentCtrl); + + this.editSupportRequestDialog = $("#editSupportRequest-dialog").dialog({ + autoOpen: false, + modal: true, + width: 400, + overlay: { + backgroundColor: '#000', + opacity: 0.5 + }, + buttons: { + "Save Changes": $self.updateSupportRequest, + Cancel: function () { + $self.editSupportRequestDialog.dialog("close"); + } + }, + close: function () { + $('#editSupportRequest-form')[0].reset(); + $self.editSupportRequestFields.removeClass("ui-state-error"); + } + }); - ko.applyBindings(editViewModel, $self.editSupportRequestForm); + this.editSupportRequest = function (supportRequestViewModel) { - $self.editSupportRequestDialog.dialog('option', 'title', 'Edit SR-' + supportRequestViewModel.Key); - $self.editSupportRequestDialog.dialog('open'); - return false; - }; + var editViewModel = new EditViewModel($self.editUrl); + editViewModel.issue = supportRequestViewModel; + editViewModel.assignedToChoices = $self.assignedToChoices; + editViewModel.issueStatusChoices = $self.issueStatusChoices.filter(function (value) { + return value.Text !== 'Unresolved'; + }); - this.generateContactUserUrl = function (supportRequestViewModel) { - return 'mailto:' + supportRequestViewModel.OwnerEmail - + '?subject=[NuGet.org Support] ' + supportRequestViewModel.IssueTitle - + '&CC=support@nuget.org'; - }; + editViewModel.editAssignedToId = supportRequestViewModel.AssignedTo; + editViewModel.editIssueStatusId = supportRequestViewModel.IssueStatusId; - this.showHistory = function (supportRequestViewModel) { + ko.applyBindings(editViewModel, $self.editSupportRequestForm); - var url = $self.generateHistoryUrl(supportRequestViewModel); + $self.editSupportRequestDialog.dialog('option', 'title', 'Edit SR-' + supportRequestViewModel.Key); + $self.editSupportRequestDialog.dialog('open'); + return false; + }; - $.ajax({ - url: url, - type: 'GET', - cache: false, - dataType: 'json', - success: function (data) { + this.generateContactUserUrl = function (supportRequestViewModel) { + return 'mailto:' + supportRequestViewModel.OwnerEmail + + '?subject=[NuGet.org Support] ' + supportRequestViewModel.IssueTitle + + '&CC=support@nuget.org'; + }; - var historyViewModel = ko.dataFor($self.historyTableCtrl); - historyViewModel.issue(supportRequestViewModel); - historyViewModel.historyEntries(data); + this.showHistory = function (supportRequestViewModel) { - $self.historyDialog.dialog('option', 'title', 'History for SR-' + supportRequestViewModel.Key); - $self.historyDialog.dialog('open'); - } - }) - .error(function (jqXhr, textStatus, errorThrown) { - alert("Error: " + errorThrown); - }); + var url = $self.generateHistoryUrl(supportRequestViewModel); - return false; - }; + $.ajax({ + url: url, + type: 'GET', + cache: false, + dataType: 'json', + success: function (data) { - this.generateUserProfileUrl = function (supportRequestViewModel) { - if (supportRequestViewModel.CreatedBy.toUpperCase !== 'ANONYMOUS') { - return supportRequestViewModel.SiteRoot + 'Profiles/' + supportRequestViewModel.CreatedBy; - } - return '#'; - } + var historyViewModel = ko.dataFor($self.historyTableCtrl); + historyViewModel.issue(supportRequestViewModel); + historyViewModel.historyEntries(data); - this.generatePackageDetailsUrl = function(supportRequestViewModel) { - return supportRequestViewModel.SiteRoot + 'packages/' + supportRequestViewModel.PackageId + '/' + supportRequestViewModel.PackageVersion; - } + $self.historyDialog.dialog('option', 'title', 'History for SR-' + supportRequestViewModel.Key); + $self.historyDialog.dialog('open'); + } + }) + .error(function (jqXhr, textStatus, errorThrown) { + alert("Error: " + errorThrown); + }); - this.generateHistoryUrl = function (supportRequestViewModel) { - return $self.historyUrl + '?id=' + supportRequestViewModel.Key; - } + return false; + }; - this.getStyleForIssueStatus = function (supportRequestViewModel) { - if (supportRequestViewModel.IssueStatusName.toUpperCase() === 'NEW') { - return 'color: #FF1F19; style: bold;'; + this.generateUserProfileUrl = function (supportRequestViewModel) { + if (supportRequestViewModel.CreatedBy.toUpperCase !== 'ANONYMOUS') { + return supportRequestViewModel.SiteRoot + 'Profiles/' + supportRequestViewModel.CreatedBy; + } + return '#'; } - else if (supportRequestViewModel.IssueStatusName.toUpperCase() === 'RESOLVED') { - return 'color: #09B25B; style: bold;'; + + this.generatePackageDetailsUrl = function (supportRequestViewModel) { + return supportRequestViewModel.SiteRoot + 'packages/' + supportRequestViewModel.PackageId + '/' + supportRequestViewModel.PackageVersion; } - else { - return 'color: #FF8D00; style: bold;'; + + this.generateHistoryUrl = function (supportRequestViewModel) { + return $self.historyUrl + '?id=' + supportRequestViewModel.Key; } - } - this.applyFilter = function () { - $self.filter($self.pageNumber(), $self.take()); - }; + this.getStyleForIssueStatus = function (supportRequestViewModel) { + if (supportRequestViewModel.IssueStatusName.toUpperCase() === 'NEW') { + return 'color: #FF1F19; style: bold;'; + } + else if (supportRequestViewModel.IssueStatusName.toUpperCase() === 'RESOLVED') { + return 'color: #09B25B; style: bold;'; + } + else { + return 'color: #FF8D00; style: bold;'; + } + } - this.filter = function (pageNumber, take) { + this.applyFilter = function () { + $self.filter($self.pageNumber(), $self.take()); + }; - var url = $self.filterUrl + '?pageNumber=' + pageNumber + '&take=' + take; + this.filter = function (pageNumber, take) { - if ($self.assignedToFilter() !== undefined) { - url += '&assignedToId=' + $self.assignedToFilter(); - } + var url = $self.filterUrl + '?pageNumber=' + pageNumber + '&take=' + take; - if ($self.reasonFilter() !== undefined && $self.reasonFilter() !== '') { - url += '&reason=' + $self.reasonFilter(); - } + if ($self.assignedToFilter() !== undefined) { + url += '&assignedToId=' + $self.assignedToFilter(); + } - if ($self.issueStatusIdFilter() !== undefined) { - url += '&issueStatusId=' + $self.issueStatusIdFilter(); - } + if ($self.reasonFilter() !== undefined && $self.reasonFilter() !== '') { + url += '&reason=' + $self.reasonFilter(); + } - $.ajax({ - url: url, - type: 'GET', - cache: false, - dataType: 'json', - success: function (data) { - var parsed = JSON.parse(data); - $self.filteredIssues(parsed.Issues); - $self.pageNumber(parsed.CurrentPageNumber); - $self.maxPageNumber(parsed.MaxPage); - $self.styleButtons(); + if ($self.issueStatusIdFilter() !== undefined) { + url += '&issueStatusId=' + $self.issueStatusIdFilter(); } - }) - .error(function (jqXhr, textStatus, errorThrown) { - alert("Error: " + errorThrown); - }); - }; -}; - -$(function () { - ko.bindingHandlers.datetime = { - update: function (element, valueAccessor) { - var value = valueAccessor(); - var date = moment(value); - $(element).text(date.format("L") + " " + date.format("LTS")); - } + + $.ajax({ + url: url, + type: 'GET', + cache: false, + dataType: 'json', + success: function (data) { + var parsed = JSON.parse(data); + $self.filteredIssues(parsed.Issues); + $self.pageNumber(parsed.CurrentPageNumber); + $self.maxPageNumber(parsed.MaxPage); + $self.styleButtons(); + } + }) + .error(function (jqXhr, textStatus, errorThrown) { + alert("Error: " + errorThrown); + }); + }; }; -}); \ No newline at end of file + +}()); + +(function () { + 'use strict'; + + $(function () { + ko.bindingHandlers.datetime = { + update: function (element, valueAccessor) { + var value = valueAccessor(); + var date = moment(value); + $(element).text(date.format("L") + " " + date.format("LTS")); + } + }; + }); +}()); diff --git a/src/NuGetGallery/Security/AutomaticOverwriteRequiredSignerPolicy.cs b/src/NuGetGallery/Security/AutomaticOverwriteRequiredSignerPolicy.cs new file mode 100644 index 0000000000..92f974bb6e --- /dev/null +++ b/src/NuGetGallery/Security/AutomaticOverwriteRequiredSignerPolicy.cs @@ -0,0 +1,19 @@ +// 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.Security +{ + /// + /// A policy which enables subscribing package owners to automatically + /// overwrite the current required signer for a package registration. + /// + public sealed class AutomaticallyOverwriteRequiredSignerPolicy : RequiredSignerPolicy + { + public const string PolicyName = nameof(AutomaticallyOverwriteRequiredSignerPolicy); + + public AutomaticallyOverwriteRequiredSignerPolicy() + : base(PolicyName, SecurityPolicyAction.AutomaticallyOverwriteRequiredSigner) + { + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Security/ControlRequiredSignerPolicy.cs b/src/NuGetGallery/Security/ControlRequiredSignerPolicy.cs new file mode 100644 index 0000000000..bd66c9d83c --- /dev/null +++ b/src/NuGetGallery/Security/ControlRequiredSignerPolicy.cs @@ -0,0 +1,20 @@ +// 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.Security +{ + /// + /// A policy which enables subscribing package owners to retain control + /// from non-subscribing package owners of changing the required signer + /// for a package registration. + /// + public sealed class ControlRequiredSignerPolicy : RequiredSignerPolicy + { + public const string PolicyName = nameof(ControlRequiredSignerPolicy); + + public ControlRequiredSignerPolicy() + : base(PolicyName, SecurityPolicyAction.ControlRequiredSigner) + { + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Security/RequiredSignerPolicy.cs b/src/NuGetGallery/Security/RequiredSignerPolicy.cs new file mode 100644 index 0000000000..ccbf4d4077 --- /dev/null +++ b/src/NuGetGallery/Security/RequiredSignerPolicy.cs @@ -0,0 +1,40 @@ +// 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.Threading.Tasks; + +namespace NuGetGallery.Security +{ + public abstract class RequiredSignerPolicy : UserSecurityPolicyHandler, IUserSecurityPolicySubscription + { + public IEnumerable Policies { get; } + + public string SubscriptionName => Name; + + public RequiredSignerPolicy(string policyName, SecurityPolicyAction action) + : base(policyName, action) + { + Policies = new[] + { + new UserSecurityPolicy(policyName, policyName) + }; + } + + public override SecurityPolicyResult Evaluate(UserSecurityPolicyEvaluationContext context) + { + throw new NotImplementedException(); // Not used. + } + + public Task OnSubscribeAsync(UserSecurityPolicySubscriptionContext context) + { + return Task.CompletedTask; + } + + public Task OnUnsubscribeAsync(UserSecurityPolicySubscriptionContext context) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Security/SecurityPolicyAction.cs b/src/NuGetGallery/Security/SecurityPolicyAction.cs index 1362ee6d83..c99be97fe7 100644 --- a/src/NuGetGallery/Security/SecurityPolicyAction.cs +++ b/src/NuGetGallery/Security/SecurityPolicyAction.cs @@ -8,6 +8,8 @@ public enum SecurityPolicyAction PackagePush, PackageVerify, ManagePackageOwners, - JoinOrganization + JoinOrganization, + AutomaticallyOverwriteRequiredSigner, + ControlRequiredSigner } } \ No newline at end of file diff --git a/src/NuGetGallery/Security/SecurityPolicyService.cs b/src/NuGetGallery/Security/SecurityPolicyService.cs index 25ad68fd00..1b7b62cb23 100644 --- a/src/NuGetGallery/Security/SecurityPolicyService.cs +++ b/src/NuGetGallery/Security/SecurityPolicyService.cs @@ -19,6 +19,10 @@ public class SecurityPolicyService : ISecurityPolicyService { private static Lazy> _userHandlers = new Lazy>(CreateUserHandlers); + private static readonly ControlRequiredSignerPolicy _controlRequiredSignerPolicy + = new ControlRequiredSignerPolicy(); + private static readonly AutomaticallyOverwriteRequiredSignerPolicy _automaticallyOverwriteRequiredSignerPolicy + = new AutomaticallyOverwriteRequiredSignerPolicy(); protected IEntitiesContext EntitiesContext { get; set; } @@ -67,7 +71,8 @@ public virtual IEnumerable UserSubscriptions { get { - return new List(); + yield return _controlRequiredSignerPolicy; + yield return _automaticallyOverwriteRequiredSignerPolicy; } } @@ -359,6 +364,8 @@ private static IEnumerable CreateUserHandlers() yield return new RequirePackageVerifyScopePolicy(); yield return new RequireMinProtocolVersionForPushPolicy(); yield return new RequireOrganizationTenantPolicy(); + yield return _controlRequiredSignerPolicy; + yield return _automaticallyOverwriteRequiredSignerPolicy; } } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/AccountDeletionOrphanPackagePolicy.cs b/src/NuGetGallery/Services/AccountDeletionOrphanPackagePolicy.cs new file mode 100644 index 0000000000..f50b83a41e --- /dev/null +++ b/src/NuGetGallery/Services/AccountDeletionOrphanPackagePolicy.cs @@ -0,0 +1,23 @@ +// 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 +{ + public enum AccountDeletionOrphanPackagePolicy + { + /// + /// Any orphan packages created by deleting the account should remain listed. + /// + KeepOrphans, + + /// + /// Any orphan packages created by deleting the account should be unlisted. + /// + UnlistOrphans, + + /// + /// Deleting the account should not create any orphan packages. + /// + DoNotAllowOrphans, + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/ActionsRequiringPermissions.cs b/src/NuGetGallery/Services/ActionsRequiringPermissions.cs index 7cfb15e2a4..9f2727c51a 100644 --- a/src/NuGetGallery/Services/ActionsRequiringPermissions.cs +++ b/src/NuGetGallery/Services/ActionsRequiringPermissions.cs @@ -10,10 +10,14 @@ public static class ActionsRequiringPermissions { private const PermissionsRequirement RequireOwnerOrSiteAdmin = PermissionsRequirement.Owner | PermissionsRequirement.SiteAdmin; + private const PermissionsRequirement RequireOwnerOrSiteAdminOrOrganizationAdmin = + PermissionsRequirement.Owner | PermissionsRequirement.SiteAdmin | PermissionsRequirement.OrganizationAdmin; private const PermissionsRequirement RequireOwnerOrOrganizationAdmin = PermissionsRequirement.Owner | PermissionsRequirement.OrganizationAdmin; - private const PermissionsRequirement RequireOwnerOrOrganizationMember = + private const PermissionsRequirement RequireOwnerOrOrganizationMember = PermissionsRequirement.Owner | PermissionsRequirement.OrganizationAdmin | PermissionsRequirement.OrganizationCollaborator; + private const PermissionsRequirement RequireOwnerOrSiteAdminOrOrganizationMember = + PermissionsRequirement.Owner | PermissionsRequirement.SiteAdmin | PermissionsRequirement.OrganizationAdmin | PermissionsRequirement.OrganizationCollaborator; /// /// The action of seeing private metadata about a package. @@ -95,6 +99,13 @@ public static class ActionsRequiringPermissions new ActionRequiringAccountPermissions( accountPermissionsRequirement: RequireOwnerOrOrganizationAdmin); + /// + /// The action of viewing (read-only) a user or organization account. + /// + public static ActionRequiringAccountPermissions ViewAccount = + new ActionRequiringAccountPermissions( + accountPermissionsRequirement: RequireOwnerOrSiteAdminOrOrganizationMember); + /// /// The action of managing a user or organization account. This includes confirming an account, /// changing the email address, changing email subscriptions, modifying sign-in credentials, etc. @@ -104,10 +115,18 @@ public static class ActionsRequiringPermissions accountPermissionsRequirement: RequireOwnerOrOrganizationAdmin); /// - /// The action of viewing (read-only) a user or organization account. + /// The action of managing an organization's memberships. /// - public static ActionRequiringAccountPermissions ViewAccount = + public static ActionRequiringAccountPermissions ManageMembership = new ActionRequiringAccountPermissions( - accountPermissionsRequirement: RequireOwnerOrOrganizationMember); + accountPermissionsRequirement: RequireOwnerOrSiteAdminOrOrganizationAdmin); + + /// + /// The action of changing a package's required signer. + /// + public static ActionRequiringPackagePermissions ManagePackageRequiredSigner = + new ActionRequiringPackagePermissions( + accountOnBehalfOfPermissionsRequirement: RequireOwnerOrOrganizationAdmin, + packageRegistrationPermissionsRequirement: RequireOwnerOrOrganizationAdmin); } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/CertificateService.cs b/src/NuGetGallery/Services/CertificateService.cs new file mode 100644 index 0000000000..3b5d3aa282 --- /dev/null +++ b/src/NuGetGallery/Services/CertificateService.cs @@ -0,0 +1,194 @@ +// 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.Data.Entity; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using NuGetGallery.Auditing; + +namespace NuGetGallery +{ + public sealed class CertificateService : ICertificateService + { + private readonly ICertificateValidator _certificateValidator; + private readonly IEntityRepository _certificateRepository; + private readonly IEntityRepository _userRepository; + private readonly IEntitiesContext _entitiesContext; + private readonly IFileStorageService _fileStorageService; + private readonly IAuditingService _auditingService; + private readonly ITelemetryService _telemetryService; + + public CertificateService( + ICertificateValidator certificateValidator, + IEntityRepository certificateRepository, + IEntityRepository userRepository, + IEntitiesContext entitiesContext, + IFileStorageService fileStorageService, + IAuditingService auditingService, + ITelemetryService telemetryService) + { + _certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator)); + _certificateRepository = certificateRepository ?? throw new ArgumentNullException(nameof(certificateRepository)); + _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); + _entitiesContext = entitiesContext ?? throw new ArgumentNullException(nameof(entitiesContext)); + _fileStorageService = fileStorageService ?? throw new ArgumentNullException(nameof(fileStorageService)); + _auditingService = auditingService ?? throw new ArgumentNullException(nameof(auditingService)); + _telemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); + } + + public async Task AddCertificateAsync(HttpPostedFileBase file) + { + if (file == null) + { + throw new ArgumentNullException(nameof(file)); + } + + _certificateValidator.Validate(file); + + using (var certificateFile = CertificateFile.Create(file.InputStream)) + { + var certificate = GetCertificate(certificateFile.Sha256Thumbprint); + + if (certificate == null) + { + await SaveToFileStorageAsync(certificateFile); + + certificate = new Certificate() + { + Sha1Thumbprint = certificateFile.Sha1Thumbprint, + Thumbprint = certificateFile.Sha256Thumbprint, + UserCertificates = new List() + }; + + _certificateRepository.InsertOnCommit(certificate); + + await _certificateRepository.CommitChangesAsync(); + + await _auditingService.SaveAuditRecordAsync( + new CertificateAuditRecord(AuditedCertificateAction.Add, certificate.Thumbprint)); + + _telemetryService.TrackCertificateAdded(certificateFile.Sha256Thumbprint); + } + + return certificate; + } + } + + public async Task ActivateCertificateAsync(string thumbprint, User account) + { + if (string.IsNullOrEmpty(thumbprint)) + { + throw new ArgumentException(Strings.ArgumentCannotBeNullOrEmpty, nameof(thumbprint)); + } + + if (account == null) + { + throw new ArgumentNullException(nameof(account)); + } + + var certificate = GetCertificate(thumbprint); + + if (certificate == null) + { + throw new ArgumentException(Strings.CertificateDoesNotExist, nameof(thumbprint)); + } + + var userCertificate = certificate.UserCertificates.SingleOrDefault(uc => uc.UserKey == account.Key); + + if (userCertificate == null) + { + userCertificate = new UserCertificate() + { + CertificateKey = certificate.Key, + UserKey = account.Key + }; + + _entitiesContext.UserCertificates.Add(userCertificate); + + await _entitiesContext.SaveChangesAsync(); + + await _auditingService.SaveAuditRecordAsync( + new CertificateAuditRecord(AuditedCertificateAction.Activate, certificate.Thumbprint)); + + _telemetryService.TrackCertificateActivated(thumbprint); + } + } + + public async Task DeactivateCertificateAsync(string thumbprint, User account) + { + if (string.IsNullOrEmpty(thumbprint)) + { + throw new ArgumentException(Strings.ArgumentCannotBeNullOrEmpty, nameof(thumbprint)); + } + + if (account == null) + { + throw new ArgumentNullException(nameof(account)); + } + + var certificate = GetCertificate(thumbprint); + + if (certificate == null) + { + throw new ArgumentException(Strings.CertificateDoesNotExist, nameof(thumbprint)); + } + + var userCertificate = certificate.UserCertificates.SingleOrDefault(uc => uc.UserKey == account.Key); + + if (userCertificate != null) + { + _entitiesContext.DeleteOnCommit(userCertificate); + + await _entitiesContext.SaveChangesAsync(); + + await _auditingService.SaveAuditRecordAsync( + new CertificateAuditRecord(AuditedCertificateAction.Deactivate, certificate.Thumbprint)); + + _telemetryService.TrackCertificateDeactivated(thumbprint); + } + } + + public IEnumerable GetCertificates(User account) + { + if (account == null) + { + throw new ArgumentNullException(nameof(account)); + } + + return _userRepository.GetAll() + .Where(u => u.Key == account.Key) + .SelectMany(u => u.UserCertificates) + .Select(uc => uc.Certificate); + } + + private async Task SaveToFileStorageAsync(CertificateFile certificateFile) + { + var filePath = $"SHA-256/{certificateFile.Sha256Thumbprint}{CoreConstants.CertificateFileExtension}"; + + try + { + await _fileStorageService.SaveFileAsync( + CoreConstants.UserCertificatesFolderName, + filePath, + certificateFile.Stream, + overwrite: false); + } + catch (FileAlreadyExistsException) + { + // A certificate is being uploaded again. + // The fact that the certificate already exists in storage is ignorable. + } + } + + private Certificate GetCertificate(string thumbprint) + { + return _certificateRepository.GetAll() + .Where(c => c.Thumbprint == thumbprint) + .Include(c => c.UserCertificates) + .SingleOrDefault(); + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/CertificateValidator.cs b/src/NuGetGallery/Services/CertificateValidator.cs new file mode 100644 index 0000000000..867b4f055a --- /dev/null +++ b/src/NuGetGallery/Services/CertificateValidator.cs @@ -0,0 +1,165 @@ +// 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Web; + +namespace NuGetGallery +{ + public sealed class CertificateValidator : ICertificateValidator + { + private const int MaximumSizeInBytes = 10000; + + public void Validate(HttpPostedFileBase file) + { + if (file == null) + { + throw new UserSafeException(Strings.CertificateFileIsRequired, new ArgumentNullException(nameof(file))); + } + + if (!string.Equals( + Path.GetExtension(file.FileName), + CoreConstants.CertificateFileExtension, + StringComparison.OrdinalIgnoreCase)) + { + throw new UserSafeException( + string.Format( + CultureInfo.InvariantCulture, + Strings.ValidateCertificate_InvalidFileType, + CoreConstants.CertificateFileExtension)); + } + + var stream = file.InputStream; + + if (stream == null) + { + throw new UserSafeException(Strings.ValidateCertificate_InvalidStream); + } + + if (!stream.CanSeek) + { + throw new UserSafeException(Strings.ValidateCertificate_StreamMustBeSeekable); + } + + if (file.ContentLength <= 0 || stream.Length <= 0) + { + throw new UserSafeException(Strings.ValidateCertificate_InvalidFileLength); + } + + if (file.ContentLength > MaximumSizeInBytes || stream.Length > MaximumSizeInBytes) + { + throw new UserSafeException( + string.Format( + CultureInfo.InvariantCulture, + Strings.ValidateCertificate_FileTooLarge, + MaximumSizeInBytes)); + } + + if (!IsDerEncodedX509Certificate(stream)) + { + throw new UserSafeException(Strings.ValidateCertificate_InvalidEncoding); + } + } + + private static bool IsDerEncodedX509Certificate(Stream stream) + { + stream.Position = 0; + + try + { + using (var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true)) + { + var firstByte = reader.ReadByte(); + + const byte ConstructedSequence = 0x30; + + // A DER encoded binary X.509 certificate begins with a constructed sequence tag. + if (firstByte != ConstructedSequence) + { + return false; + } + + // However, so do many other DER encoded files (e.g.: PFX, P7B, P7S, etc.). + // We will have reasonable confidence that this file is a certificate file and not one + // of these other types by inspecting the next ASN.1 tag. But first we need to read the + // length for the current sequence. + + var value = reader.ReadByte(); + int length; + + if ((value & 0x80) == 0x80) + { + // Length is in long form. + // The length is represented by a byte sequence of length byteCount. + var byteCount = value & 0x7F; + + // The previously checked constructed sequence tag and initial length byte + // subtract from the overall length. + var maxByteCount = GetLengthByteCount(MaximumSizeInBytes - 2); + + if (byteCount > maxByteCount) + { + // The sequence is larger than the maximum file size allows, + // so don't even try calculating the actual length. + return false; + } + + var lengthBytes = reader.ReadBytes(byteCount); + + length = lengthBytes.Aggregate( + seed: 0, + func: (l, r) => (l * 256) + r); + } + else + { + // Length is in short form. + length = value; + } + + var remainingBytes = stream.Length - stream.Position; + + if (length != remainingBytes) + { + return false; + } + + if (stream.Position == stream.Length) + { + return false; + } + + // A X.509 certificate (https://tools.ietf.org/html/rfc5280#section-4.1) is a SEQUENCE + // with a SEQUENCE as its first nested type. + // + // In contrast, a PKCS #12 file (https://tools.ietf.org/html/rfc7292#section-4) is a SEQUENCE + // with an INTEGER as its first nested type. + return reader.ReadByte() == ConstructedSequence; + } + } + finally + { + stream.Position = 0; + } + } + + private static int GetLengthByteCount(uint value) + { + var digits = 1; + + while (value > 255) + { + var digit = value % 256; + + value /= 256; + + ++digits; + } + + return digits; + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/CloudDownloadCountService.cs b/src/NuGetGallery/Services/CloudDownloadCountService.cs index 8d019752d0..cbe2d423bb 100644 --- a/src/NuGetGallery/Services/CloudDownloadCountService.cs +++ b/src/NuGetGallery/Services/CloudDownloadCountService.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -27,9 +28,8 @@ public class CloudDownloadCountService : IDownloadCountService private readonly object _refreshLock = new object(); private bool _isRefreshing; - private readonly IDictionary> _downloadCounts = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - public DateTime LastRefresh { get; protected set; } + private readonly ConcurrentDictionary> _downloadCounts + = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); public CloudDownloadCountService(ITelemetryClient telemetryClient, string connectionString, bool readAccessGeoRedundant) { @@ -38,7 +38,7 @@ public CloudDownloadCountService(ITelemetryClient telemetryClient, string connec _connectionString = connectionString; _readAccessGeoRedundant = readAccessGeoRedundant; } - + public bool TryGetDownloadCountForPackageRegistration(string id, out int downloadCount) { if (string.IsNullOrEmpty(id)) @@ -46,11 +46,9 @@ public bool TryGetDownloadCountForPackageRegistration(string id, out int downloa throw new ArgumentNullException(nameof(id)); } - id = id.ToLowerInvariant(); - - if (_downloadCounts.ContainsKey(id)) + if (_downloadCounts.TryGetValue(id, out var versions)) { - downloadCount = _downloadCounts[id].Sum(kvp => kvp.Value); + downloadCount = CalculateSum(versions); return true; } @@ -70,16 +68,10 @@ public bool TryGetDownloadCountForPackage(string id, string version, out int dow throw new ArgumentNullException(nameof(version)); } - id = id.ToLowerInvariant(); - version = version.ToLowerInvariant(); - - if (_downloadCounts.ContainsKey(id)) + if (_downloadCounts.TryGetValue(id, out var versions) + && versions.TryGetValue(version, out downloadCount)) { - if (_downloadCounts[id].ContainsKey(version)) - { - downloadCount = _downloadCounts[id][version]; - return true; - } + return true; } downloadCount = 0; @@ -127,78 +119,102 @@ public void Refresh() finally { _isRefreshing = false; - LastRefresh = DateTime.UtcNow; } } } + /// + /// This method is added for unit testing purposes. + /// + protected virtual int CalculateSum(ConcurrentDictionary versions) + { + return versions.Sum(kvp => kvp.Value); + } + + /// + /// This method is added for unit testing purposes. It can return a null stream if the blob does not exist + /// and assumes the caller will properly dispose of the returned stream. + /// + protected virtual Stream GetBlobStream() + { + var blob = GetBlobReference(); + if (blob == null) + { + return null; + } + + return blob.OpenRead(); + } + private void RefreshCore() { try { - var blob = GetBlobReference(); - if (blob == null) - { - return; - } - // The data in downloads.v1.json will be an array of Package records - which has Id, Array of Versions and download count. // Sample.json : [["AutofacContrib.NSubstitute",["2.4.3.700",406],["2.5.0",137]],["Assman.Core",["2.0.7",138]].... - using (var jsonReader = new JsonTextReader(new StreamReader(blob.OpenRead()))) + using (var blobStream = GetBlobStream()) { - try + if (blobStream == null) { - jsonReader.Read(); + return; + } - while (jsonReader.Read()) + using (var jsonReader = new JsonTextReader(new StreamReader(blobStream))) + { + try { - try + jsonReader.Read(); + + while (jsonReader.Read()) { - if (jsonReader.TokenType == JsonToken.StartArray) + try { - JToken record = JToken.ReadFrom(jsonReader); - string id = record[0].ToString().ToLowerInvariant(); - - // The second entry in each record should be an array of versions, if not move on to next entry. - // This is a check to safe guard against invalid entries. - if (record.Count() == 2 && record[1].Type != JTokenType.Array) + if (jsonReader.TokenType == JsonToken.StartArray) { - continue; - } + JToken record = JToken.ReadFrom(jsonReader); + string id = record[0].ToString().ToLowerInvariant(); - if (!_downloadCounts.ContainsKey(id)) - { - _downloadCounts.Add(id, new Dictionary(StringComparer.OrdinalIgnoreCase)); - } - var versions = _downloadCounts[id]; + // The second entry in each record should be an array of versions, if not move on to next entry. + // This is a check to safe guard against invalid entries. + if (record.Count() == 2 && record[1].Type != JTokenType.Array) + { + continue; + } - foreach (JToken token in record) - { - if (token != null && token.Count() == 2) + var versions = _downloadCounts.GetOrAdd( + id, + _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); + + foreach (JToken token in record) { - string version = token[0].ToString().ToLowerInvariant(); - versions[version] = token[1].ToObject(); + if (token != null && token.Count() == 2) + { + var version = token[0].ToString(); + var downloadCount = token[1].ToObject(); + + versions.AddOrSet(version, downloadCount); + } } } } - } - catch (JsonReaderException ex) - { - _telemetryClient.TrackException(ex, new Dictionary + catch (JsonReaderException ex) { - { "Origin", TelemetryOriginForRefreshMethod }, - { "AdditionalInfo", "Invalid entry found in downloads.v1.json." } - }); + _telemetryClient.TrackException(ex, new Dictionary + { + { "Origin", TelemetryOriginForRefreshMethod }, + { "AdditionalInfo", "Invalid entry found in downloads.v1.json." } + }); + } } } - } - catch (JsonReaderException ex) - { - _telemetryClient.TrackException(ex, new Dictionary + catch (JsonReaderException ex) { - { "Origin", TelemetryOriginForRefreshMethod }, - { "AdditionalInfo", "Data present in downloads.v1.json is invalid. Couldn't get download data." } - }); + _telemetryClient.TrackException(ex, new Dictionary + { + { "Origin", TelemetryOriginForRefreshMethod }, + { "AdditionalInfo", "Data present in downloads.v1.json is invalid. Couldn't get download data." } + }); + } } } } diff --git a/src/NuGetGallery/Services/DeleteAccountService.cs b/src/NuGetGallery/Services/DeleteAccountService.cs index 1932461bc1..4a795217e4 100644 --- a/src/NuGetGallery/Services/DeleteAccountService.cs +++ b/src/NuGetGallery/Services/DeleteAccountService.cs @@ -1,5 +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.Collections.Generic; using System.Globalization; @@ -50,30 +51,20 @@ IAuditingService auditingService _auditingService = auditingService ?? throw new ArgumentNullException(nameof(auditingService)); } - /// - /// Will clean-up the data related with an user account. - /// The result will be: - /// 1. The user will be removed as owner from its owned packages. - /// 2. Any of the packages that become orphaned as its result will be unlisted if the unlistOrphanPackages is set to true. - /// 3. Any owned namespaces will be released. - /// 4. The user credentials will be cleaned. - /// 5. The user data will be cleaned. - /// - /// The user to be deleted. - /// The admin that will perform the delete action. - /// The admin signature. - /// If the orphaned packages will unlisted. - /// If the data will be persisted as a transaction. - /// - public async Task DeleteGalleryUserAccountAsync(User userToBeDeleted, User admin, string signature, bool unlistOrphanPackages, bool commitAsTransaction) + public async Task DeleteAccountAsync(User userToBeDeleted, + User userToExecuteTheDelete, + bool commitAsTransaction, + AccountDeletionOrphanPackagePolicy orphanPackagePolicy = AccountDeletionOrphanPackagePolicy.DoNotAllowOrphans, + string signature = null) { if (userToBeDeleted == null) { throw new ArgumentNullException(nameof(userToBeDeleted)); } - if (admin == null) + + if (userToExecuteTheDelete == null) { - throw new ArgumentNullException(nameof(admin)); + throw new ArgumentNullException(nameof(userToExecuteTheDelete)); } if (userToBeDeleted.IsDeleted) @@ -87,91 +78,51 @@ public async Task DeleteGalleryUserAccountAsync(User us AccountName = userToBeDeleted.Username }; } + + return await RunAccountDeletionTask( + () => DeleteAccountImplAsync( + userToBeDeleted, + userToExecuteTheDelete, + orphanPackagePolicy, + signature ?? userToExecuteTheDelete.Username), + userToBeDeleted, + userToExecuteTheDelete, + commitAsTransaction); + } - // The deletion of Organization and Organization member accounts is disabled for now. - if (userToBeDeleted is Organization) - { - return new DeleteUserAccountStatus() - { - Success = false, - Description = string.Format(CultureInfo.CurrentCulture, - Strings.AccountDelete_OrganizationDeleteNotImplemented, - userToBeDeleted.Username), - AccountName = userToBeDeleted.Username - }; - } - else if (userToBeDeleted.Organizations.Any()) + private async Task DeleteAccountImplAsync(User userToBeDeleted, User userToExecuteTheDelete, AccountDeletionOrphanPackagePolicy orphanPackagePolicy, string signature) + { + await RemoveReservedNamespaces(userToBeDeleted); + await RemovePackageOwnership(userToBeDeleted, userToExecuteTheDelete, orphanPackagePolicy); + await RemoveMemberships(userToBeDeleted, userToExecuteTheDelete, orphanPackagePolicy, signature); + await RemoveSecurityPolicies(userToBeDeleted); + await RemoveUserCredentials(userToBeDeleted); + await RemovePackageOwnershipRequests(userToBeDeleted); + + var organizationToBeDeleted = userToBeDeleted as Organization; + if (organizationToBeDeleted != null) { - return new DeleteUserAccountStatus() - { - Success = false, - Description = string.Format(CultureInfo.CurrentCulture, - Strings.AccountDelete_OrganizationMemberDeleteNotImplemented, - userToBeDeleted.Username), - AccountName = userToBeDeleted.Username - }; + await RemoveMembers(organizationToBeDeleted); } - try + if (!userToBeDeleted.Confirmed) { - // The support requests db and gallery db are different. - // TransactionScope can be used for doing transaction actions across db on the same server but not on different servers. - // The below code will clean first the suppport requests and after the gallery data. - // The order is important in order to allow the admin the oportunity to execute this step again. - await RemoveSupportRequests(userToBeDeleted); - - if (commitAsTransaction) - { - using (var strategy = new SuspendDbExecutionStrategy()) - using (var transaction = _entitiesContext.GetDatabase().BeginTransaction()) - { - await DeleteGalleryUserAccountImplAsync(userToBeDeleted, admin, signature, unlistOrphanPackages); - transaction.Commit(); - } - } - else - { - await DeleteGalleryUserAccountImplAsync(userToBeDeleted, admin, signature, unlistOrphanPackages); - } - await _auditingService.SaveAuditRecordAsync(new DeleteAccountAuditRecord(username: userToBeDeleted.Username, - status: DeleteAccountAuditRecord.ActionStatus.Success, - action: AuditedDeleteAccountAction.DeleteAccount, - adminUsername: admin.Username)); - return new DeleteUserAccountStatus() - { - Success = true, - Description = string.Format(CultureInfo.CurrentCulture, - Strings.AccountDelete_Success, - userToBeDeleted.Username), - AccountName = userToBeDeleted.Username - }; + // Unconfirmed users should be hard-deleted. + // Another account with the same username can be created. + await RemoveUser(userToBeDeleted); } - catch(Exception e) + else { - QuietLog.LogHandledException(e); - return new DeleteUserAccountStatus() - { - Success = true, - Description = string.Format(CultureInfo.CurrentCulture, - Strings.AccountDelete_Fail, - userToBeDeleted.Username, e), - AccountName = userToBeDeleted.Username - }; + // Confirmed users should be soft-deleted. + // Another account with the same username cannot be created. + await RemoveUserDataInUserTable(userToBeDeleted); + await InsertDeleteAccount( + userToBeDeleted, + userToExecuteTheDelete, + signature); } } - private async Task DeleteGalleryUserAccountImplAsync(User userToBeDeleted, User admin, string signature, bool unlistOrphanPackages) - { - var ownedPackages = _packageService.FindPackagesByAnyMatchingOwner(userToBeDeleted, includeUnlisted: true, includeVersions: true).ToList(); - - await RemoveOwnership(userToBeDeleted, admin, unlistOrphanPackages, ownedPackages); - await RemoveReservedNamespaces(userToBeDeleted); - await RemoveSecurityPolicies(userToBeDeleted); - await RemoveUserCredentials(userToBeDeleted); - await RemoveUserDataInUserTable(userToBeDeleted); - await InsertDeleteAccount(userToBeDeleted, admin, signature); - } - private async Task InsertDeleteAccount(User user, User admin, string signature) { var accountDelete = new AccountDelete @@ -212,16 +163,108 @@ private async Task RemoveReservedNamespaces(User user) } } - private async Task RemoveOwnership(User user, User admin, bool unlistOrphanPackages, List packages) + private async Task RemovePackageOwnership(User user, User requestingUser, AccountDeletionOrphanPackagePolicy orphanPackagePolicy) + { + foreach (var package in GetPackagesOwnedByUser(user)) + { + var owners = user is Organization ? package.PackageRegistration.Owners : _packageService.GetPackageUserAccountOwners(package); + if (owners.Count() <= 1) + { + // Package will be orphaned by removing ownership. + if (orphanPackagePolicy == AccountDeletionOrphanPackagePolicy.DoNotAllowOrphans) + { + throw new InvalidOperationException($"Deleting user '{user.Username}' will make package '{package.PackageRegistration.Id}' an orphan, but no orphans were expected."); + } + else if (orphanPackagePolicy == AccountDeletionOrphanPackagePolicy.UnlistOrphans) + { + await _packageService.MarkPackageUnlistedAsync(package, commitChanges: true); + } + } + + await _packageOwnershipManagementService.RemovePackageOwnerAsync(package.PackageRegistration, requestingUser, user, commitAsTransaction:false); + } + } + + private bool WillPackageBeOrphaned(User user, Package package) + { + var owners = user is Organization ? package.PackageRegistration.Owners : _packageService.GetPackageUserAccountOwners(package); + return owners.Count() <= 1; + } + + private List GetPackagesOwnedByUser(User user) + { + return _packageService.FindPackagesByAnyMatchingOwner(user, includeUnlisted: true, includeVersions: true).ToList(); + } + + private async Task RemovePackageOwnershipRequests(User user) + { + var requests = _packageOwnershipManagementService.GetPackageOwnershipRequests(newOwner: user).ToList(); + foreach (var request in requests) + { + await _packageOwnershipManagementService.DeletePackageOwnershipRequestAsync(request.PackageRegistration, request.NewOwner); + } + } + + private async Task RemoveMemberships(User user, User requestingUser, AccountDeletionOrphanPackagePolicy orphanPackagePolicy, string signature) { - foreach (var package in packages) + foreach (var membership in user.Organizations.ToArray()) { - if (unlistOrphanPackages && _packageService.GetPackageUserAccountOwners(package).Count() <= 1) + var organization = membership.Organization; + var members = organization.Members.ToList(); + var collaborators = members.Where(m => !m.IsAdmin).ToList(); + var memberCount = members.Count(); + user.Organizations.Remove(membership); + + if (memberCount < 2) + { + // The user we are deleting is the only member of the organization. + // We should delete the entire organization. + await DeleteAccountImplAsync(organization, requestingUser, orphanPackagePolicy, signature); + } + else if (memberCount - 1 <= collaborators.Count()) { - await _packageService.MarkPackageUnlistedAsync(package, commitChanges: true); + // All other members of this organization are collaborators, so we should promote them to administrators. + foreach (var collaborator in collaborators) + { + collaborator.IsAdmin = true; + } } - await _packageOwnershipManagementService.RemovePackageOwnerAsync(package.PackageRegistration, admin, user, commitAsTransaction:false); } + + foreach (var membershipRequest in user.OrganizationRequests.ToArray()) + { + user.OrganizationRequests.Remove(membershipRequest); + } + + foreach (var transformationRequest in user.OrganizationMigrationRequests.ToArray()) + { + user.OrganizationMigrationRequests.Remove(transformationRequest); + transformationRequest.NewOrganization.OrganizationMigrationRequest = null; + } + + var migrationRequest = user.OrganizationMigrationRequest; + user.OrganizationMigrationRequest = null; + if (migrationRequest != null) + { + migrationRequest.AdminUser.OrganizationMigrationRequests.Remove(migrationRequest); + } + + await _entitiesContext.SaveChangesAsync(); + } + + private async Task RemoveMembers(Organization organization) + { + foreach (var membership in organization.Members.ToList()) + { + organization.Members.Remove(membership); + } + + foreach (var memberRequest in organization.MemberRequests.ToList()) + { + organization.MemberRequests.Remove(memberRequest); + } + + await _entitiesContext.SaveChangesAsync(); } private async Task RemoveUserDataInUserTable(User user) @@ -234,5 +277,63 @@ private async Task RemoveSupportRequests(User user) { await _supportRequestService.DeleteSupportRequestsAsync(user.Username); } + + private async Task RemoveUser(User user) + { + _userRepository.DeleteOnCommit(user); + await _userRepository.CommitChangesAsync(); + } + + private async Task RunAccountDeletionTask(Func getTask, User userToBeDeleted, User requestingUser, bool commitAsTransaction) + { + try + { + // The support requests DB and gallery DB are different. + // TransactionScope can be used for doing transaction actions across db on the same server but not on different servers. + // The below code will clean the suppport requests before the gallery data. + // The order is important in order to allow the admin the opportunity to execute this step again. + await RemoveSupportRequests(userToBeDeleted); + + if (commitAsTransaction) + { + using (var strategy = new SuspendDbExecutionStrategy()) + using (var transaction = _entitiesContext.GetDatabase().BeginTransaction()) + { + await getTask(); + transaction.Commit(); + } + } + else + { + await getTask(); + } + + await _auditingService.SaveAuditRecordAsync(new DeleteAccountAuditRecord(username: userToBeDeleted.Username, + status: DeleteAccountAuditRecord.ActionStatus.Success, + action: AuditedDeleteAccountAction.DeleteAccount, + adminUsername: requestingUser.Username)); + + return new DeleteUserAccountStatus() + { + Success = true, + Description = string.Format(CultureInfo.CurrentCulture, + Strings.AccountDelete_Success, + userToBeDeleted.Username), + AccountName = userToBeDeleted.Username + }; + } + catch (Exception e) + { + QuietLog.LogHandledException(e); + return new DeleteUserAccountStatus() + { + Success = false, + Description = string.Format(CultureInfo.CurrentCulture, + Strings.AccountDelete_Fail, + userToBeDeleted.Username, e), + AccountName = userToBeDeleted.Username + }; + } + } } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/FileSystemFileStorageService.cs b/src/NuGetGallery/Services/FileSystemFileStorageService.cs index 2c0846b25c..1b57169905 100644 --- a/src/NuGetGallery/Services/FileSystemFileStorageService.cs +++ b/src/NuGetGallery/Services/FileSystemFileStorageService.cs @@ -146,7 +146,7 @@ public Task SaveFileAsync(string folderName, string fileName, Stream packageFile var dirPath = Path.GetDirectoryName(filePath); _fileSystemService.CreateDirectory(dirPath); - + try { using (var file = _fileSystemService.OpenWrite(filePath, overwrite)) @@ -156,8 +156,8 @@ public Task SaveFileAsync(string folderName, string fileName, Stream packageFile } catch (IOException ex) { - throw new InvalidOperationException( - String.Format( + throw new FileAlreadyExistsException( + string.Format( CultureInfo.CurrentCulture, "There is already a file with name {0} in folder {1}.", fileName, @@ -206,14 +206,14 @@ public Task CopyFileAsync( var destFilePath = BuildPath(_configuration.FileStorageDirectory, destFolderName, destFileName); _fileSystemService.CreateDirectory(Path.GetDirectoryName(destFilePath)); - + try { _fileSystemService.Copy(srcFilePath, destFilePath, overwrite: false); } catch (IOException e) { - throw new InvalidOperationException("Could not copy because destination file already exists", e); + throw new FileAlreadyExistsException("Could not copy because destination file already exists", e); } return Task.FromResult(null); diff --git a/src/NuGetGallery/Services/ICertificateService.cs b/src/NuGetGallery/Services/ICertificateService.cs new file mode 100644 index 0000000000..0a4ed424d7 --- /dev/null +++ b/src/NuGetGallery/Services/ICertificateService.cs @@ -0,0 +1,53 @@ +// 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.Threading.Tasks; +using System.Web; + +namespace NuGetGallery +{ + public interface ICertificateService + { + /// + /// Add a certificate to the database if the certificate does not already exist. + /// + /// The certificate file. + /// A task that represents the asynchronous operation. + /// The task result () returns a + /// entity. + /// Thrown if is null. + Task AddCertificateAsync(HttpPostedFileBase file); + + /// + /// Activates an existing certificate for an account. + /// + /// The certificate thumbprint. + /// The account. + /// A task that represents the asynchronous operation. + /// Thrown if is null + /// or empty or if a certificate with the specified thumbprint does not exist. + /// Thrown if is null. + Task ActivateCertificateAsync(string thumbprint, User account); + + /// + /// Deactivates an existing certificate for an account. + /// + /// The certificate thumbprint. + /// The account. + /// A task that represents the asynchronous operation. + /// Thrown if is null + /// or empty or if a certificate with the specified thumbprint does not exist. + /// Thrown if is null. + Task DeactivateCertificateAsync(string thumbprint, User account); + + /// + /// Gets certificates associated with the specified account. + /// + /// The account. + /// An enumerable of entities. + /// Thrown if is null. + IEnumerable GetCertificates(User account); + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/ICertificateValidator.cs b/src/NuGetGallery/Services/ICertificateValidator.cs new file mode 100644 index 0000000000..a082cf7bf4 --- /dev/null +++ b/src/NuGetGallery/Services/ICertificateValidator.cs @@ -0,0 +1,21 @@ +// 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.Web; + +namespace NuGetGallery +{ + /// + /// Represents a certificate validator. + /// + public interface ICertificateValidator + { + /// + /// Validates a certificate. + /// + /// A certificate file. + /// Thrown if is null. + void Validate(HttpPostedFileBase file); + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Services/IDeleteAccountService.cs b/src/NuGetGallery/Services/IDeleteAccountService.cs index 6c7eb0ffd6..94682a318a 100644 --- a/src/NuGetGallery/Services/IDeleteAccountService.cs +++ b/src/NuGetGallery/Services/IDeleteAccountService.cs @@ -9,14 +9,23 @@ namespace NuGetGallery public interface IDeleteAccountService { /// - /// Deletes an user gallery account. + /// Will clean-up the data related with an user account. + /// The result will be: + /// 1. The user will be removed as owner from its owned packages. + /// 2. Any of the packages that become orphaned as its result will be handled according to . + /// 3. Any owned namespaces will be released. + /// 4. The user credentials will be cleaned. + /// 5. The user data will be cleaned. /// /// The user to be deleted. - /// The admin that will execute the delete action. - /// The admin signature. - /// True if the orphan packages will be unlisted. - /// True if the changes will commited as a transaction. - /// - Task DeleteGalleryUserAccountAsync(User userToBeDeleted, User admin, string signature, bool unsignOrphanPackages, bool commitAsTransaction); + /// The user deleting the account. + /// If deleting the account creates any orphaned packages, a that describes how those orphans should be handled. + /// Whether or not to commit the changes as a transaction. + /// The signature of the user deleting the account. + Task DeleteAccountAsync(User userToBeDeleted, + User userToExecuteTheDelete, + bool commitAsTransaction, + AccountDeletionOrphanPackagePolicy orphanPackagePolicy = AccountDeletionOrphanPackagePolicy.DoNotAllowOrphans, + string signature = null); } } diff --git a/src/NuGetGallery/Services/IMessageService.cs b/src/NuGetGallery/Services/IMessageService.cs index 17e7ff9eab..159dbc0114 100644 --- a/src/NuGetGallery/Services/IMessageService.cs +++ b/src/NuGetGallery/Services/IMessageService.cs @@ -27,7 +27,7 @@ public interface IMessageService 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(MailAddress mailAddress, string userName); + void SendAccountDeleteNotice(User user); void SendPackageDeletedNotice(Package package, string packageUrl, string packageSupportUrl); void SendSigninAssistanceEmail(MailAddress emailAddress, IEnumerable credentials); void SendOrganizationTransformRequest(User accountToTransform, User adminUser, string profileUrl, string confirmationUrl, string rejectionUrl); diff --git a/src/NuGetGallery/Services/IPackageService.cs b/src/NuGetGallery/Services/IPackageService.cs index 3d2562c71a..ada26712e0 100644 --- a/src/NuGetGallery/Services/IPackageService.cs +++ b/src/NuGetGallery/Services/IPackageService.cs @@ -16,8 +16,6 @@ namespace NuGetGallery /// public interface IPackageService : ICorePackageService { - PackageRegistration FindPackageRegistrationById(string id); - /// /// Gets the package with the given ID and version when exists; /// otherwise gets the latest package version for the given package ID matching the provided constraints. @@ -86,5 +84,24 @@ public interface IPackageService : ICorePackageService /// The package. /// The list of package owners that are not organizations. IEnumerable GetPackageUserAccountOwners(Package package); + + /// + /// Sets the required signer on all owned package registrations. + /// + /// A signer or null if none. + /// A task that represents the asynchronous operation. + /// Thrown if + /// is null. + Task SetRequiredSignerAsync(User signer); + + /// + /// Sets the required signer on an owned package registration. + /// + /// A package registration. + /// A signer or null if none. + /// A task that represents the asynchronous operation. + /// Thrown if + /// is null. + Task SetRequiredSignerAsync(PackageRegistration registration, User signer); } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/ITelemetryService.cs b/src/NuGetGallery/Services/ITelemetryService.cs index ae0977dea9..43cd6440ca 100644 --- a/src/NuGetGallery/Services/ITelemetryService.cs +++ b/src/NuGetGallery/Services/ITelemetryService.cs @@ -35,6 +35,8 @@ public interface ITelemetryService void TrackNewUserRegistrationEvent(User user, Credential identity); + void TrackUserChangedMultiFactorAuthentication(User user, bool enabledMultiFactorAuth); + void TrackNewCredentialCreated(User user, Credential credential); /// @@ -47,6 +49,38 @@ public interface ITelemetryService /// void TrackUserPackageDeleteExecuted(int packageKey, string packageId, string packageVersion, ReportPackageReason reason, bool success); + /// + /// A telemetry event emitted when a certificate is added to the database. + /// + /// The certificate thumbprint. + /// Thrown if is null + /// or empty. + void TrackCertificateAdded(string thumbprint); + + /// + /// A telemetry event emitted when a certificate is activated for an account. + /// + /// The certificate thumbprint. + /// Thrown if is null + /// or empty. + void TrackCertificateActivated(string thumbprint); + + /// + /// A telemetry event emitted when a certificate is deactivated for an account. + /// + /// The certificate thumbprint. + /// Thrown if is null + /// or empty. + void TrackCertificateDeactivated(string thumbprint); + + /// + /// A telemetry event emitted when the required signer is set on a package registration. + /// + /// The package ID. + /// Thrown if is null + /// or empty. + void TrackRequiredSignerSet(string packageId); + /// /// A telemetry event emitted when a user requests transformation of their account into an organization. /// diff --git a/src/NuGetGallery/Services/IUserService.cs b/src/NuGetGallery/Services/IUserService.cs index 1e9b1d7e5b..24bd2627b6 100644 --- a/src/NuGetGallery/Services/IUserService.cs +++ b/src/NuGetGallery/Services/IUserService.cs @@ -28,9 +28,9 @@ public interface IUserService IList FindByUnconfirmedEmailAddress(string unconfirmedEmailAddress, string optionalUsername); - User FindByUsername(string username); + User FindByUsername(string username, bool includeDeleted = false); - User FindByKey(int key); + User FindByKey(int key, bool includeDeleted = false); Task ConfirmEmailAddress(User user, string token); @@ -38,6 +38,8 @@ public interface IUserService Task CancelChangeEmailAddress(User user); + Task ChangeMultiFactorAuthentication(User user, bool enableMultiFactor); + Task> GetEmailAddressesForUserKeysAsync(IReadOnlyCollection distinctUserKeys); bool CanTransformUserToOrganization(User accountToTransform, out string errorReason); diff --git a/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs b/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs index 7213ef3b4a..9c465441b0 100644 --- a/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs +++ b/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs @@ -17,15 +17,13 @@ public class LoginDiscontinuationConfiguration : ILoginDiscontinuationConfigurat internal HashSet ExceptionsForEmailAddresses { get; } internal HashSet ForceTransformationToOrganizationForEmailAddresses { get; } internal HashSet EnabledOrganizationAadTenants { get; } - internal bool OrganizationsEnabledForAll { get; } public LoginDiscontinuationConfiguration() : this(Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty(), - Enumerable.Empty(), - organizationsEnabledForAll: false) + Enumerable.Empty()) { } @@ -35,15 +33,13 @@ public LoginDiscontinuationConfiguration( IEnumerable discontinuedForDomains, IEnumerable exceptionsForEmailAddresses, IEnumerable forceTransformationToOrganizationForEmailAddresses, - IEnumerable enabledOrganizationAadTenants, - bool organizationsEnabledForAll) + IEnumerable enabledOrganizationAadTenants) { DiscontinuedForEmailAddresses = new HashSet(discontinuedForEmailAddresses, StringComparer.OrdinalIgnoreCase); DiscontinuedForDomains = new HashSet(discontinuedForDomains, StringComparer.OrdinalIgnoreCase); ExceptionsForEmailAddresses = new HashSet(exceptionsForEmailAddresses, StringComparer.OrdinalIgnoreCase); ForceTransformationToOrganizationForEmailAddresses = new HashSet(forceTransformationToOrganizationForEmailAddresses, StringComparer.OrdinalIgnoreCase); EnabledOrganizationAadTenants = new HashSet(enabledOrganizationAadTenants, new OrganizationTenantPairComparer()); - OrganizationsEnabledForAll = organizationsEnabledForAll; } public bool IsLoginDiscontinued(AuthenticatedUser authUser) @@ -73,13 +69,6 @@ public bool IsUserOnWhitelist(User user) DiscontinuedForEmailAddresses.Contains(email.Address); } - public bool AreOrganizationsSupportedForUser(User user) - { - return OrganizationsEnabledForAll || - (user != null && - (user.Organizations.Any() || IsUserOnWhitelist(user))); - } - public bool ShouldUserTransformIntoOrganization(User user) { if (user == null) @@ -101,7 +90,6 @@ public interface ILoginDiscontinuationConfiguration { bool IsLoginDiscontinued(AuthenticatedUser authUser); bool IsUserOnWhitelist(User user); - bool AreOrganizationsSupportedForUser(User user); bool ShouldUserTransformIntoOrganization(User user); bool IsTenantIdPolicySupportedForOrganization(string emailAddress, string tenantId); } diff --git a/src/NuGetGallery/Services/MessageService.cs b/src/NuGetGallery/Services/MessageService.cs index e7b27c068c..75619bafdd 100644 --- a/src/NuGetGallery/Services/MessageService.cs +++ b/src/NuGetGallery/Services/MessageService.cs @@ -638,29 +638,17 @@ private void SendSupportMessage(User user, string body, string subject) public void SendPackageUploadedNotice(Package package, string packageUrl, string packageSupportUrl, string emailSettingsUrl) { - string subject = "[{0}] Package uploaded - {1} {2}"; - string body = @"The package [{1} {2}]({3}) was just uploaded to {0}. If this was not intended, please [contact support]({4}). + 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 {0} and - [change your email notification settings]({5}). + To stop receiving emails as an owner of this package, sign in to the {Config.GalleryOwner.DisplayName} and + [change your email notification settings]({emailSettingsUrl}). "; - body = String.Format( - CultureInfo.CurrentCulture, - body, - Config.GalleryOwner.DisplayName, - package.PackageRegistration.Id, - package.Version, - packageUrl, - packageSupportUrl, - emailSettingsUrl); - - subject = String.Format(CultureInfo.CurrentCulture, subject, Config.GalleryOwner.DisplayName, package.PackageRegistration.Id, package.Version); - using (var mailMessage = new MailMessage()) { mailMessage.Subject = subject; @@ -676,7 +664,7 @@ [change your email notification settings]({5}). } } - public void SendAccountDeleteNotice(MailAddress mailAddress, string account) + 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. {2}When your account will be deleted, we will:{2} @@ -692,7 +680,7 @@ public void SendAccountDeleteNotice(MailAddress mailAddress, string account) body = String.Format( CultureInfo.CurrentCulture, body, - account, + user, Config.GalleryOwner.DisplayName, Environment.NewLine); @@ -702,7 +690,7 @@ public void SendAccountDeleteNotice(MailAddress mailAddress, string account) mailMessage.Body = body; mailMessage.From = Config.GalleryNoReplyAddress; - mailMessage.To.Add(mailAddress.Address); + mailMessage.To.Add(user.ToMailAddress()); SendMessage(mailMessage); } } diff --git a/src/NuGetGallery/Services/PackageService.cs b/src/NuGetGallery/Services/PackageService.cs index 45513aee66..2a19c890c4 100644 --- a/src/NuGetGallery/Services/PackageService.cs +++ b/src/NuGetGallery/Services/PackageService.cs @@ -12,27 +12,31 @@ using NuGet.Versioning; using NuGetGallery.Auditing; using NuGetGallery.Packaging; +using NuGetGallery.Security; namespace NuGetGallery { public class PackageService : CorePackageService, IPackageService { - private readonly IEntityRepository _packageRegistrationRepository; private readonly IPackageNamingConflictValidator _packageNamingConflictValidator; private readonly IAuditingService _auditingService; private readonly ITelemetryService _telemetryService; + private readonly ISecurityPolicyService _securityPolicyService; public PackageService( IEntityRepository packageRegistrationRepository, IEntityRepository packageRepository, + IEntityRepository certificateRepository, IPackageNamingConflictValidator packageNamingConflictValidator, IAuditingService auditingService, - ITelemetryService telemetryService) : base(packageRepository) + ITelemetryService telemetryService, + ISecurityPolicyService securityPolicyService) + : base(packageRepository, packageRegistrationRepository, certificateRepository) { - _packageRegistrationRepository = packageRegistrationRepository ?? throw new ArgumentNullException(nameof(packageRegistrationRepository)); _packageNamingConflictValidator = packageNamingConflictValidator ?? throw new ArgumentNullException(nameof(packageNamingConflictValidator)); _auditingService = auditingService ?? throw new ArgumentNullException(nameof(auditingService)); _telemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); + _securityPolicyService = securityPolicyService ?? throw new ArgumentNullException(nameof(securityPolicyService)); } /// @@ -110,16 +114,16 @@ public async Task CreatePackageAsync(PackageArchiveReader nugetPackage, return package; } - public virtual PackageRegistration FindPackageRegistrationById(string id) + public override PackageRegistration FindPackageRegistrationById(string packageId) { - if (id == null) + if (packageId == null) { - throw new ArgumentNullException(nameof(id)); + throw new ArgumentNullException(nameof(packageId)); } return _packageRegistrationRepository.GetAll() .Include(pr => pr.Owners) - .SingleOrDefault(pr => pr.Id == id); + .SingleOrDefault(pr => pr.Id == packageId); } public virtual Package FindPackageByIdAndVersion( @@ -220,7 +224,7 @@ public virtual Package FindAbsoluteLatestPackageById(string id, int? semVerLevel public IEnumerable FindPackagesByOwner(User user, bool includeUnlisted, bool includeVersions = false) { - return GetPackagesForOwners(new [] { user.Key }, includeUnlisted, includeVersions); + return GetPackagesForOwners(new[] { user.Key }, includeUnlisted, includeVersions); } /// @@ -348,7 +352,13 @@ public async Task PublishPackageAsync(Package package, bool commitChanges = true public async Task AddPackageOwnerAsync(PackageRegistration package, User newOwner) { package.Owners.Add(newOwner); + await _packageRepository.CommitChangesAsync(); + + if (_securityPolicyService.IsSubscribed(newOwner, AutomaticallyOverwriteRequiredSignerPolicy.PolicyName)) + { + await SetRequiredSignerAsync(package, newOwner); + } } public async Task RemovePackageOwnerAsync(PackageRegistration package, User user) @@ -430,7 +440,7 @@ public async Task MarkPackageUnlistedAsync(Package package, bool commitChanges = private PackageRegistration CreateOrGetPackageRegistration(User owner, User currentUser, PackageMetadata packageMetadata, bool isVerified) { var packageRegistration = FindPackageRegistrationById(packageMetadata.Id); - + if (packageRegistration == null) { if (_packageNamingConflictValidator.IdConflictsWithExistingPackageTitle(packageMetadata.Id)) @@ -707,5 +717,126 @@ public virtual async Task UpdatePackageVerifiedStatusAsync(IReadOnlyCollection

+ /// Asynchronously sets the signer as the required signer on all package registrations owned by the signer. + ///

+ /// A user. + /// A task that represents the asynchronous operation. + /// Thrown if is null. + public async Task SetRequiredSignerAsync(User signer) + { + if (signer == null) + { + throw new ArgumentNullException(nameof(signer)); + } + + var registrations = FindPackageRegistrationsByOwner(signer); + var auditRecords = new List(); + var packageIds = new List(); + var isCommitRequired = false; + + foreach (var registration in registrations) + { + string previousRequiredSigner = null; + string newRequiredSigner = null; + + if (!registration.RequiredSigners.Contains(signer)) + { + previousRequiredSigner = registration.RequiredSigners.FirstOrDefault()?.Username; + + registration.RequiredSigners.Clear(); + + isCommitRequired = true; + + registration.RequiredSigners.Add(signer); + + newRequiredSigner = signer.Username; + + var auditRecord = PackageRegistrationAuditRecord.CreateForSetRequiredSigner( + registration, + previousRequiredSigner, + newRequiredSigner); + + auditRecords.Add(auditRecord); + packageIds.Add(registration.Id); + } + } + + if (isCommitRequired) + { + await _packageRegistrationRepository.CommitChangesAsync(); + + foreach (var auditRecord in auditRecords) + { + await _auditingService.SaveAuditRecordAsync(auditRecord); + } + + foreach (var packageId in packageIds) + { + _telemetryService.TrackRequiredSignerSet(packageId); + } + } + } + + /// + /// Asynchronously sets the signer as the required signer on a single package registration owned by the signer. + /// + /// A package registration. + /// A user. May be null. + /// A task that represents the asynchronous operation. + /// Thrown if is null. + public async Task SetRequiredSignerAsync(PackageRegistration registration, User signer) + { + if (registration == null) + { + throw new ArgumentNullException(nameof(registration)); + } + + var isCommitRequired = false; + + string previousRequiredSigner = null; + string newRequiredSigner = null; + + if (signer == null) + { + var currentRequiredSigner = registration.RequiredSigners.FirstOrDefault(); + + if (currentRequiredSigner != null) + { + previousRequiredSigner = currentRequiredSigner.Username; + + registration.RequiredSigners.Clear(); + + isCommitRequired = true; + } + } + else if (!registration.RequiredSigners.Contains(signer)) + { + previousRequiredSigner = registration.RequiredSigners.FirstOrDefault()?.Username; + + registration.RequiredSigners.Clear(); + + isCommitRequired = true; + + registration.RequiredSigners.Add(signer); + + newRequiredSigner = signer.Username; + } + + if (isCommitRequired) + { + await _packageRegistrationRepository.CommitChangesAsync(); + + var auditRecord = PackageRegistrationAuditRecord.CreateForSetRequiredSigner( + registration, + previousRequiredSigner, + newRequiredSigner); + + await _auditingService.SaveAuditRecordAsync(auditRecord); + + _telemetryService.TrackRequiredSignerSet(registration.Id); + } + } } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/TelemetryService.cs b/src/NuGetGallery/Services/TelemetryService.cs index 346c282c3b..063d9dc870 100644 --- a/src/NuGetGallery/Services/TelemetryService.cs +++ b/src/NuGetGallery/Services/TelemetryService.cs @@ -24,6 +24,8 @@ internal class Events public const string CredentialAdded = "CredentialAdded"; public const string UserPackageDeleteCheckedAfterHours = "UserPackageDeleteCheckedAfterHours"; public const string UserPackageDeleteExecuted = "UserPackageDeleteExecuted"; + public const string UserMultiFactorAuthenticationEnabled = "UserMultiFactorAuthenticationEnabled"; + public const string UserMultiFactorAuthenticationDisabled = "UserMultiFactorAuthenticationDisabled"; public const string PackageReflow = "PackageReflow"; public const string PackageUnlisted = "PackageUnlisted"; public const string PackageListed = "PackageListed"; @@ -35,6 +37,10 @@ internal class Events public const string OrganizationTransformDeclined = "OrganizationTransformDeclined"; public const string OrganizationTransformCancelled = "OrganizationTransformCancelled"; public const string OrganizationAdded = "OrganizationAdded"; + public const string CertificateAdded = "CertificateAdded"; + public const string CertificateActivated = "CertificateActivated"; + public const string CertificateDeactivated = "CertificateDeactivated"; + public const string PackageRegistrationRequiredSignerSet = "PackageRegistrationRequiredSignerSet"; } private IDiagnosticsSource _diagnosticsSource; @@ -89,6 +95,9 @@ internal class Events public const string OrganizationAccountKey = "OrganizationAccountKey"; public const string OrganizationIsRestrictedToOrganizationTenantPolicy = "OrganizationIsRestrictedToOrganizationTenantPolicy"; + // Certificate properties + public const string Sha256Thumbprint = "Sha256Thumbprint"; + public TelemetryService(IDiagnosticsService diagnosticsService, ITelemetryClient telemetryClient = null) { if (diagnosticsService == null) @@ -196,6 +205,13 @@ public void TrackNewUserRegistrationEvent(User user, Credential credential) TrackMetricForAccountActivity(Events.NewUserRegistration, user, credential); } + public void TrackUserChangedMultiFactorAuthentication(User user, bool enabledMultiFactorAuth) + { + TrackMetricForAccountActivity(enabledMultiFactorAuth ? Events.UserMultiFactorAuthenticationEnabled : Events.UserMultiFactorAuthenticationDisabled, + user, + credential: null); + } + public void TrackNewCredentialCreated(User user, Credential credential) { TrackMetricForAccountActivity(Events.CredentialAdded, user, credential); @@ -255,6 +271,33 @@ public void TrackPackageRevalidate(Package package) TrackMetricForPackage(Events.PackageRevalidate, package); } + public void TrackCertificateAdded(string thumbprint) + { + TrackMetricForCertificateActivity(Events.CertificateAdded, thumbprint); + } + + public void TrackCertificateActivated(string thumbprint) + { + TrackMetricForCertificateActivity(Events.CertificateActivated, thumbprint); + } + + public void TrackCertificateDeactivated(string thumbprint) + { + TrackMetricForCertificateActivity(Events.CertificateDeactivated, thumbprint); + } + + public void TrackRequiredSignerSet(string packageId) + { + if (string.IsNullOrEmpty(packageId)) + { + throw new ArgumentException(Strings.ArgumentCannotBeNullOrEmpty, nameof(packageId)); + } + + TrackMetric(Events.PackageRegistrationRequiredSignerSet, 1, properties => { + properties.Add(PackageId, packageId); + }); + } + public void TrackException(Exception exception, Action> addProperties) { var telemetryProperties = new Dictionary(); @@ -271,11 +314,6 @@ private void TrackMetricForAccountActivity(string eventName, User user, Credenti throw new ArgumentNullException(nameof(user)); } - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - TrackMetric(eventName, 1, properties => { properties.Add(ClientVersion, GetClientVersion()); properties.Add(ProtocolVersion, GetProtocolVersion()); @@ -284,6 +322,18 @@ private void TrackMetricForAccountActivity(string eventName, User user, Credenti }); } + private void TrackMetricForCertificateActivity(string eventName, string thumbprint) + { + if (string.IsNullOrEmpty(thumbprint)) + { + throw new ArgumentException(Strings.ArgumentCannotBeNullOrEmpty, nameof(thumbprint)); + } + + TrackMetric(eventName, 1, properties => { + properties.Add(Sha256Thumbprint, thumbprint); + }); + } + private static string GetClientVersion() { return HttpContext.Current?.Request?.Headers[Constants.ClientVersionHeaderName]; @@ -312,7 +362,7 @@ private static string GetAccountCreationDate(User user) private static string GetRegistrationMethod(Credential cred) { - return cred.Type; + return cred?.Type ?? ""; } private static string GetApiKeyCreationDate(User user, IIdentity identity) diff --git a/src/NuGetGallery/Services/UserService.cs b/src/NuGetGallery/Services/UserService.cs index c71ddef8cf..9c159ae184 100644 --- a/src/NuGetGallery/Services/UserService.cs +++ b/src/NuGetGallery/Services/UserService.cs @@ -36,6 +36,8 @@ public class UserService : IUserService public IDateTimeProvider DateTimeProvider { get; protected set; } + public ITelemetryService TelemetryService { get; protected set; } + protected UserService() { } public UserService( @@ -48,7 +50,8 @@ public UserService( IContentObjectService contentObjectService, ISecurityPolicyService securityPolicyService, IDateTimeProvider dateTimeProvider, - ICredentialBuilder credentialBuilder) + ICredentialBuilder credentialBuilder, + ITelemetryService telemetryService) : this() { Config = config; @@ -60,6 +63,7 @@ public UserService( ContentObjectService = contentObjectService; SecurityPolicyService = securityPolicyService; DateTimeProvider = dateTimeProvider; + TelemetryService = telemetryService; } public async Task AddMembershipRequestAsync(Organization organization, string memberName, bool isAdmin) @@ -214,12 +218,16 @@ public async Task AddMemberAsync(Organization organization, string m IsAdmin = request.IsAdmin }; organization.Members.Add(membership); + + await Auditing.SaveAuditRecordAsync(new UserAuditRecord(organization, AuditedUserAction.AddOrganizationMember, membership)); } else { // If the user is already a member, update the existing membership. // If the request grants admin but this member is not an admin, grant admin to the member. membership.IsAdmin = membership.IsAdmin || request.IsAdmin; + + await Auditing.SaveAuditRecordAsync(new UserAuditRecord(organization, AuditedUserAction.UpdateOrganizationMember, membership)); } await EntitiesContext.SaveChangesAsync(); @@ -247,6 +255,9 @@ public async Task UpdateMemberAsync(Organization organization, strin } membership.IsAdmin = isAdmin; + + await Auditing.SaveAuditRecordAsync(new UserAuditRecord(organization, AuditedUserAction.UpdateOrganizationMember, membership)); + await EntitiesContext.SaveChangesAsync(); } @@ -273,6 +284,9 @@ public async Task DeleteMemberAsync(Organization organization, string memb } organization.Members.Remove(membership); + + await Auditing.SaveAuditRecordAsync(new UserAuditRecord(organization, AuditedUserAction.RemoveOrganizationMember, membership)); + await EntitiesContext.SaveChangesAsync(); return memberToRemove; @@ -338,18 +352,26 @@ public virtual IList FindByUnconfirmedEmailAddress(string unconfirmedEmail } } - public virtual User FindByUsername(string username) + public virtual User FindByUsername(string username, bool includeDeleted = false) { - return UserRepository.GetAll() - .Include(u => u.Roles) + var users = UserRepository.GetAll(); + if (!includeDeleted) + { + users = users.Where(u => !u.IsDeleted); + } + return users.Include(u => u.Roles) .Include(u => u.Credentials) .SingleOrDefault(u => u.Username == username); } - public virtual User FindByKey(int key) + public virtual User FindByKey(int key, bool includeDeleted = false) { - return UserRepository.GetAll() - .Include(u => u.Roles) + var users = UserRepository.GetAll(); + if (!includeDeleted) + { + users = users.Where(u => !u.IsDeleted); + } + return users.Include(u => u.Roles) .Include(u => u.Credentials) .SingleOrDefault(u => u.Key == key); } @@ -376,6 +398,14 @@ public async Task CancelChangeEmailAddress(User user) await UserRepository.CommitChangesAsync(); } + public virtual async Task ChangeMultiFactorAuthentication(User user, bool enableMultiFactor) + { + user.EnableMultiFactorAuthentication = enableMultiFactor; + await UserRepository.CommitChangesAsync(); + + TelemetryService.TrackUserChangedMultiFactorAuthentication(user, enableMultiFactor); + } + public async Task> GetEmailAddressesForUserKeysAsync(IReadOnlyCollection distinctUserKeys) { var results = await UserRepository.GetAll() @@ -454,11 +484,6 @@ public bool CanTransformUserToOrganization(User accountToTransform, out string e { errorReason = Strings.TransformAccount_AccountHasMemberships; } - else if (!ContentObjectService.LoginDiscontinuationConfiguration.AreOrganizationsSupportedForUser(accountToTransform)) - { - errorReason = String.Format(CultureInfo.CurrentCulture, - Strings.Organizations_NotSupportedForAccount, accountToTransform.Username); - } return errorReason == null; } @@ -492,18 +517,17 @@ public bool CanTransformUserToOrganization(User accountToTransform, User adminUs public async Task TransformUserToOrganization(User accountToTransform, User adminUser, string token) { await SubscribeOrganizationToTenantPolicyIfTenantIdIsSupported(accountToTransform, adminUser); - - return await EntitiesContext.TransformUserToOrganization(accountToTransform, adminUser, token); + var result = await EntitiesContext.TransformUserToOrganization(accountToTransform, adminUser, token); + if (result) + { + await Auditing.SaveAuditRecordAsync(new UserAuditRecord(accountToTransform, AuditedUserAction.TransformOrganization, adminUser, affectedMemberIsAdmin: true)); + } + + return result; } public async Task AddOrganizationAsync(string username, string emailAddress, User adminUser) { - if (!ContentObjectService.LoginDiscontinuationConfiguration.AreOrganizationsSupportedForUser(adminUser)) - { - throw new EntityException(String.Format(CultureInfo.CurrentCulture, - Strings.Organizations_NotSupportedForAccount, adminUser.Username)); - } - var existingUserWithIdentity = EntitiesContext.Users .FirstOrDefault(u => u.Username == username || u.EmailAddress == emailAddress); if (existingUserWithIdentity != null) @@ -538,6 +562,8 @@ public async Task AddOrganizationAsync(string username, string ema await SubscribeOrganizationToTenantPolicyIfTenantIdIsSupported(organization, adminUser, commitChanges: false); + await Auditing.SaveAuditRecordAsync(new UserAuditRecord(organization, AuditedUserAction.AddOrganization, membership)); + await EntitiesContext.SaveChangesAsync(); return organization; diff --git a/src/NuGetGallery/Strings.Designer.cs b/src/NuGetGallery/Strings.Designer.cs index 162f686c2c..fc0291249c 100644 --- a/src/NuGetGallery/Strings.Designer.cs +++ b/src/NuGetGallery/Strings.Designer.cs @@ -96,15 +96,6 @@ public static string AccountDelete_OrganizationDeleteNotImplemented { } } - /// - /// Looks up a localized string similar to Account '{0}' cannot be deleted because it is a member of an organization. The user needs to be manually removed from any organizations before the account can be deleted.. - /// - public static string AccountDelete_OrganizationMemberDeleteNotImplemented { - get { - return ResourceManager.GetString("AccountDelete_OrganizationMemberDeleteNotImplemented", resourceCulture); - } - } - /// /// Looks up a localized string similar to The account:{0} was deleted succesfully.. /// @@ -125,7 +116,7 @@ public static string AccountDelete_SupportRequestTitle { /// /// Looks up a localized string similar to The account with the email {0} is linked to another Microsoft account. - ///If you wish to update the linked Microsoft account you can do so from the account settings page.. + ///If you would like to update the linked Microsoft account you can do so from the account settings page.. /// public static string AccountIsLinkedToAnotherExternalAccount { get { @@ -133,6 +124,15 @@ public static string AccountIsLinkedToAnotherExternalAccount { } } + /// + /// Looks up a localized string similar to An exception was encoutered while trying to delete the account. Please contact support for assistance.. + /// + public static string AccountSelfDelete_Fail { + get { + return ResourceManager.GetString("AccountSelfDelete_Fail", resourceCulture); + } + } + /// /// Looks up a localized string similar to User '{0}' is already a member of this organization.. /// @@ -459,6 +459,15 @@ public static string ApiKeyUserAccountIsUnconfirmed { } } + /// + /// Looks up a localized string similar to The argument cannot be null or empty.. + /// + public static string ArgumentCannotBeNullOrEmpty { + get { + return ResourceManager.GetString("ArgumentCannotBeNullOrEmpty", resourceCulture); + } + } + ///
/// Looks up a localized string similar to The '{0}' authentication provider is disabled and cannot be used to authenticate ///. @@ -532,6 +541,24 @@ public static string CannotRemoveOnlyLoginCredential { } } + /// + /// Looks up a localized string similar to The certificate does not exist.. + /// + public static string CertificateDoesNotExist { + get { + return ResourceManager.GetString("CertificateDoesNotExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A certificate file is required.. + /// + public static string CertificateFileIsRequired { + get { + return ResourceManager.GetString("CertificateFileIsRequired", resourceCulture); + } + } + /// /// Looks up a localized string similar to Failed to update the Microsoft account with '{0}'. This could happen if it is already linked to another NuGet account. Contact support for more information.. /// @@ -714,7 +741,7 @@ public static string DisplayPackage_SecurePushRequired { } /// - /// Looks up a localized string similar to The email address '{0}' is being used.. + /// Looks up a localized string similar to The email address '{0}' is already in use by a different account.. /// public static string EmailAddressBeingUsed { get { @@ -1012,6 +1039,33 @@ public static string MissingRequiredConfigurationValue { } } + /// + /// Looks up a localized string similar to Two-factor authentication has been disabled for your account. Please close all sessions for Microsoft accounts before you log into {0} to prevent automatic enabling of this setting.. + /// + public static string MultiFactorAuth_Disabled { + get { + return ResourceManager.GetString("MultiFactorAuth_Disabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-factor authentication is enabled for your account. It will be enforced the next time you log into {0}.. + /// + public static string MultiFactorAuth_Enabled { + get { + return ResourceManager.GetString("MultiFactorAuth_Enabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to We noticed you used two-factor authentication for login. We have enabled your account to use two-factor authentication going forward.. + /// + public static string MultiFactorAuth_LoginUpdate { + get { + return ResourceManager.GetString("MultiFactorAuth_LoginUpdate", resourceCulture); + } + } + /// /// Looks up a localized string similar to Multiple Credentials match '{0}' credential with Key {1}. /// @@ -1363,6 +1417,69 @@ public static string RemoveOwner_NotOwner { } } + /// + /// Looks up a localized string similar to At least one package owner has no certificate while at least one other package owner has at least one certificate, which means future package submissions may be unsigned or signed with any certificate registered to any owner.. + /// + public static string RequiredSigner_AnyWithMixedResult { + get { + return ResourceManager.GetString("RequiredSigner_AnyWithMixedResult", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to All package owners have at least one certificate, which means future package submissions must be signed.. + /// + public static string RequiredSigner_AnyWithSignedResult { + get { + return ResourceManager.GetString("RequiredSigner_AnyWithSignedResult", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to All package owners have at least one certificate, which means future package submissions must be signed.. + /// + public static string RequiredSigner_AnyWithUnsignedResult { + get { + return ResourceManager.GetString("RequiredSigner_AnyWithUnsignedResult", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Are you sure you want to change the required signer for this package?. + /// + public static string RequiredSigner_Confirm { + get { + return ResourceManager.GetString("RequiredSigner_Confirm", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' currently has at least one certificate, which means future package submissions must be signed.. + /// + public static string RequiredSigner_OwnerHasAtLeastOneCertificate { + get { + return ResourceManager.GetString("RequiredSigner_OwnerHasAtLeastOneCertificate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' currently has no certificate, which means future package submissions must be unsigned.. + /// + public static string RequiredSigner_OwnerHasNoCertificate { + get { + return ResourceManager.GetString("RequiredSigner_OwnerHasNoCertificate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This action will change the required signer to '{0}' for all future submissions for this package.. + /// + public static string RequiredSigner_ThisAction { + get { + return ResourceManager.GetString("RequiredSigner_ThisAction", resourceCulture); + } + } + /// /// Looks up a localized string similar to The namespace '{0}' contains invalid characters. Examples of valid namespaces include 'MyNamespace' and 'MyNamespace.'.. /// @@ -2047,6 +2164,60 @@ public static string UserPackageDeleteSupportRequestMessage { } } + /// + /// Looks up a localized string similar to The file exceeds the size limit of {0} bytes.. + /// + public static string ValidateCertificate_FileTooLarge { + get { + return ResourceManager.GetString("ValidateCertificate_FileTooLarge", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file must be a DER encoded binary X.509 certificate.. + /// + public static string ValidateCertificate_InvalidEncoding { + get { + return ResourceManager.GetString("ValidateCertificate_InvalidEncoding", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file length is invalid.. + /// + public static string ValidateCertificate_InvalidFileLength { + get { + return ResourceManager.GetString("ValidateCertificate_InvalidFileLength", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file extension must be {0}.. + /// + public static string ValidateCertificate_InvalidFileType { + get { + return ResourceManager.GetString("ValidateCertificate_InvalidFileType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file stream is invalid.. + /// + public static string ValidateCertificate_InvalidStream { + get { + return ResourceManager.GetString("ValidateCertificate_InvalidStream", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file stream must be seekable.. + /// + public static string ValidateCertificate_StreamMustBeSeekable { + get { + return ResourceManager.GetString("ValidateCertificate_StreamMustBeSeekable", resourceCulture); + } + } + /// /// Looks up a localized string similar to User '{0}' does not have the rights to upload new versions of package '{1}'.. /// diff --git a/src/NuGetGallery/Strings.resx b/src/NuGetGallery/Strings.resx index 8ec8012996..f44b2e4260 100644 --- a/src/NuGetGallery/Strings.resx +++ b/src/NuGetGallery/Strings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - The email address '{0}' is being used. + The email address '{0}' is already in use by a different account. The username '{0}' is not available. @@ -670,9 +670,6 @@ For more information, please contact '{2}'. Account '{0}' cannot be deleted because it is an organization. The organization must be manually migrated to a user account without memberships before the account can be deleted. - - Account '{0}' cannot be deleted because it is a member of an organization. The user needs to be manually removed from any organizations before the account can be deleted. - Package '{0}' has been locked. This means you cannot publish a new version or change the listing status of a published package version. Please contact support@nuget.org. @@ -729,7 +726,7 @@ For more information, please contact '{2}'. The account with the email {0} is linked to another Microsoft account. -If you wish to update the linked Microsoft account you can do so from the account settings page. +If you would like to update the linked Microsoft account you can do so from the account settings page. Failed to update the Microsoft account with '{0}'. This could happen if it is already linked to another NuGet account. Contact support for more information. @@ -845,4 +842,64 @@ If you wish to update the linked Microsoft account you can do so from the accoun Account '{0}' does not support organizations. + + An exception was encoutered while trying to delete the account. Please contact support for assistance. + + + The file exceeds the size limit of {0} bytes. + + + The file must be a DER encoded binary X.509 certificate. + + + The file length is invalid. + + + The file extension must be {0}. + + + The file stream is invalid. + + + The file stream must be seekable. + + + The argument cannot be null or empty. + + + The certificate does not exist. + + + A certificate file is required. + + + Two-factor authentication is enabled for your account. It will be enforced the next time you log into {0}. + + + We noticed you used two-factor authentication for login. We have enabled your account to use two-factor authentication going forward. + + + Two-factor authentication has been disabled for your account. Please close all sessions for Microsoft accounts before you log into {0} to prevent automatic enabling of this setting. + + + At least one package owner has no certificate while at least one other package owner has at least one certificate, which means future package submissions may be unsigned or signed with any certificate registered to any owner. + + + All package owners have at least one certificate, which means future package submissions must be signed. + + + All package owners have at least one certificate, which means future package submissions must be signed. + + + Are you sure you want to change the required signer for this package? + + + '{0}' currently has at least one certificate, which means future package submissions must be signed. + + + '{0}' currently has no certificate, which means future package submissions must be unsigned. + + + This action will change the required signer to '{0}' for all future submissions for this package. + \ No newline at end of file diff --git a/src/NuGetGallery/Telemetry/Obfuscator.cs b/src/NuGetGallery/Telemetry/Obfuscator.cs index 4543d62ffd..d2c463f641 100644 --- a/src/NuGetGallery/Telemetry/Obfuscator.cs +++ b/src/NuGetGallery/Telemetry/Obfuscator.cs @@ -16,24 +16,28 @@ internal static class Obfuscator internal static readonly HashSet ObfuscatedActions = new HashSet(StringComparer.OrdinalIgnoreCase) { - "Organizations/ManageOrganization", + "Organizations/AddMember", + "Organizations/AddCertificate", + "Organizations/CancelMemberRequest", "Organizations/ChangeEmailSubscription", + "Organizations/ConfirmMemberRequest", + "Organizations/DeleteCertificate", + "Organizations/DeleteMember", + "Organizations/GetCertificate", + "Organizations/GetCertificates", + "Organizations/ManageOrganization", + "Organizations/RejectMemberRequest", + "Organizations/UpdateMember", + "Packages/CancelPendingOwnershipRequest", "Packages/ConfirmPendingOwnershipRequest", "Packages/RejectPendingOwnershipRequest", - "Packages/CancelPendingOwnershipRequest", + "Packages/SetRequiredSigner", "Users/Confirm", "Users/ConfirmTransform", "Users/Delete", "Users/Profiles", - "Users/ResetPassword", - "Users/ConfirmTransform", "Users/RejectTransform", - "Organizations/AddMember", - "Organizations/ConfirmMemberRequest", - "Organizations/RejectMemberRequest", - "Organizations/CancelMemberRequest", - "Organizations/UpdateMember", - "Organizations/DeleteMember", + "Users/ResetPassword", }; internal static string DefaultObfuscatedUrl(Uri url) diff --git a/src/NuGetGallery/UrlExtensions.cs b/src/NuGetGallery/UrlExtensions.cs index 2558ca63b0..754073867e 100644 --- a/src/NuGetGallery/UrlExtensions.cs +++ b/src/NuGetGallery/UrlExtensions.cs @@ -485,6 +485,103 @@ public static string UploadPackageProgress(this UrlHelper url, bool relativeUrl return GetRouteLink(url, RouteName.UploadPackageProgress, relativeUrl); } + public static string AddUserCertificate(this UrlHelper url, bool relativeUrl = true) + { + return GetRouteLink(url, RouteName.AddUserCertificate, relativeUrl); + } + + public static string GetUserCertificates(this UrlHelper url, bool relativeUrl = true) + { + return GetRouteLink(url, RouteName.GetUserCertificates, relativeUrl); + } + + public static string AddOrganizationCertificate(this UrlHelper url, string accountName, bool relativeUrl = true) + { + return GetRouteLink( + url, + RouteName.AddOrganizationCertificate, + relativeUrl, + routeValues: new RouteValueDictionary + { + { "accountName", accountName } + }); + } + + public static string GetOrganizationCertificates(this UrlHelper url, string accountName, bool relativeUrl = true) + { + return GetRouteLink( + url, + RouteName.GetOrganizationCertificates, + relativeUrl, + routeValues: new RouteValueDictionary + { + { "accountName", accountName } + }); + } + + public static RouteUrlTemplate DeleteUserCertificateTemplate( + this UrlHelper url, + bool relativeUrl = true) + { + var routesGenerator = new Dictionary> + { + { "thumbprint", x => x } + }; + + Func linkGenerator = rv => GetRouteLink( + url, + RouteName.DeleteUserCertificate, + relativeUrl, + routeValues: rv); + + return new RouteUrlTemplate(linkGenerator, routesGenerator); + } + + public static RouteUrlTemplate DeleteOrganizationCertificateTemplate( + this UrlHelper url, + string accountName, + bool relativeUrl = true) + { + var routesGenerator = new Dictionary> + { + { "accountName", x => accountName }, + { "thumbprint", x => x } + }; + + Func linkGenerator = rv => GetRouteLink( + url, + RouteName.DeleteOrganizationCertificate, + relativeUrl, + routeValues: rv); + + return new RouteUrlTemplate(linkGenerator, routesGenerator); + } + + /// + /// Initializes a package registration link that can be resolved at a later time. + /// + /// Callers should only use this API if they need to generate many links, such as the ManagePackages view + /// does. This template reduces the calls to RouteCollection.GetVirtualPath which can be expensive. Callers + /// that only need a single link should call Url.Package instead. + public static RouteUrlTemplate SetRequiredSignerTemplate( + this UrlHelper url, + bool relativeUrl = true) + { + var routesGenerator = new Dictionary> + { + { "id", p => p.Id }, + { "username", p => "{username}" } + }; + + Func linkGenerator = rv => GetRouteLink( + url, + RouteName.SetRequiredSigner, + relativeUrl, + routeValues: rv); + + return new RouteUrlTemplate(linkGenerator, routesGenerator); + } + public static string User( this UrlHelper url, User user, @@ -881,6 +978,18 @@ public static string DeleteOrganizationMember(this UrlHelper url, string account }); } + public static string DeleteOrganization(this UrlHelper url, string accountName, bool relativeUrl = true) + { + return GetActionLink(url, + "DeleteRequest", + "Organizations", + relativeUrl, + routeValues: new RouteValueDictionary + { + { "accountName", accountName } + }); + } + public static string ManageMyPackages(this UrlHelper url, bool relativeUrl = true) { return GetActionLink(url, "Packages", "Users", relativeUrl); @@ -1175,7 +1284,7 @@ public static string Authenticate(this UrlHelper url, string providerName, strin { { "provider", providerName }, { "returnUrl", returnUrl } - }, + }, interceptReturnUrl: false); } diff --git a/src/NuGetGallery/ViewModels/AccountViewModel.cs b/src/NuGetGallery/ViewModels/AccountViewModel.cs index d5d5ac31ce..ab46258116 100644 --- a/src/NuGetGallery/ViewModels/AccountViewModel.cs +++ b/src/NuGetGallery/ViewModels/AccountViewModel.cs @@ -5,22 +5,25 @@ namespace NuGetGallery { + public abstract class AccountViewModel : AccountViewModel where T : User + { + public T Account { get; set; } + + public override User User => Account; + } + public abstract class AccountViewModel { - public User Account { get; set; } + public virtual User User { get; } - public bool IsOrganization - { - get - { - return Account is Organization; - } - } + public bool IsOrganization => User is Organization; public string AccountName { get; set; } public bool CanManage { get; set; } + public bool WasMultiFactorAuthenticated { get; set; } + public IList CuratedFeeds { get; set; } public ChangeEmailViewModel ChangeEmail { get; set; } diff --git a/src/NuGetGallery/ViewModels/ChangePasswordViewModel.cs b/src/NuGetGallery/ViewModels/ChangePasswordViewModel.cs index 1d6ed137d9..35f931130d 100644 --- a/src/NuGetGallery/ViewModels/ChangePasswordViewModel.cs +++ b/src/NuGetGallery/ViewModels/ChangePasswordViewModel.cs @@ -10,8 +10,8 @@ namespace NuGetGallery public class ChangePasswordViewModel { [Required] - [Display(Name = "Enable Password Login")] - public bool EnablePasswordLogin { get; set; } + [Display(Name = "Disable Password Login")] + public bool DisablePasswordLogin { get; set; } [Required] [Display(Name = "Current Password")] diff --git a/src/NuGetGallery/ViewModels/DeleteAccountViewModel.cs b/src/NuGetGallery/ViewModels/DeleteAccountViewModel.cs index 1923d3ee3b..6654c8de51 100644 --- a/src/NuGetGallery/ViewModels/DeleteAccountViewModel.cs +++ b/src/NuGetGallery/ViewModels/DeleteAccountViewModel.cs @@ -4,25 +4,51 @@ using System; using System.Collections.Generic; using System.Linq; +using NuGetGallery.Areas.Admin; +using NuGetGallery.Areas.Admin.Models; namespace NuGetGallery { - public class DeleteAccountViewModel + public class DeleteAccountViewModel : DeleteAccountViewModel where TAccount : User + { + public DeleteAccountViewModel( + TAccount accountToDelete, + User currentUser, + IPackageService packageService, + Func packageIsOrphaned) + : base(accountToDelete, currentUser, packageService, packageIsOrphaned) + { + Account = accountToDelete; + } + + public TAccount Account { get; set; } + } + + public class DeleteAccountViewModel : IDeleteAccountViewModel { private Lazy _hasOrphanPackages; - public DeleteAccountViewModel() + public DeleteAccountViewModel( + User userToDelete, + User currentUser, + IPackageService packageService, + Func packageIsOrphaned) { - _hasOrphanPackages = new Lazy(() => Packages.Any(p => p.HasSingleOwner)); - } + User = userToDelete; - public List Packages { get; set; } + Packages = packageService + .FindPackagesByAnyMatchingOwner(User, includeUnlisted: true) + .Select(p => new ListPackageItemViewModel(p, currentUser)) + .ToList(); - public User User { get; set; } + _hasOrphanPackages = new Lazy(() => Packages.Any(packageIsOrphaned)); + } + + public List Packages { get; } - public string AccountName { get; set; } + public User User { get; } - public bool HasPendingRequests { get; set; } + public string AccountName => User.Username; public bool HasOrphanPackages { @@ -32,4 +58,11 @@ public bool HasOrphanPackages } } } + + public interface IDeleteAccountViewModel + { + string AccountName { get; } + + bool HasOrphanPackages { get; } + } } \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/DeleteOrganizationViewModel.cs b/src/NuGetGallery/ViewModels/DeleteOrganizationViewModel.cs new file mode 100644 index 0000000000..af9ef7b12a --- /dev/null +++ b/src/NuGetGallery/ViewModels/DeleteOrganizationViewModel.cs @@ -0,0 +1,26 @@ +// 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.Collections.Generic; +using System.Linq; + +namespace NuGetGallery +{ + public class DeleteOrganizationViewModel : DeleteAccountViewModel + { + public DeleteOrganizationViewModel( + Organization organizationToDelete, + User currentUser, + IPackageService packageService) + : base(organizationToDelete, currentUser, packageService, p => p.HasSingleOrganizationOwner) + { + AdditionalMembers = organizationToDelete.Members + .Where(m => !m.Member.MatchesUser(currentUser)) + .Select(m => new OrganizationMemberViewModel(m)); + } + + public IEnumerable AdditionalMembers { get; set; } + + public bool HasAdditionalMembers => AdditionalMembers.Any(); + } +} \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/DeleteUserViewModel.cs b/src/NuGetGallery/ViewModels/DeleteUserViewModel.cs new file mode 100644 index 0000000000..6871b49f62 --- /dev/null +++ b/src/NuGetGallery/ViewModels/DeleteUserViewModel.cs @@ -0,0 +1,34 @@ +// 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.Collections.Generic; +using System.Linq; +using NuGetGallery.Areas.Admin; +using NuGetGallery.Areas.Admin.Models; + +namespace NuGetGallery +{ + public class DeleteUserViewModel : DeleteAccountViewModel + { + public DeleteUserViewModel( + User userToDelete, + User currentUser, + IPackageService packageService, + ISupportRequestService supportRequestService) + : base(userToDelete, currentUser, packageService, p => p.HasSingleUserOwner) + { + Organizations = userToDelete.Organizations + .Select(u => new ManageOrganizationsItemViewModel(u, packageService)); + + HasPendingRequests = supportRequestService.GetIssues() + .Where(issue => + (issue.UserKey.HasValue && issue.UserKey.Value == userToDelete.Key) && + string.Equals(issue.IssueTitle, Strings.AccountDelete_SupportRequestTitle) && + issue.Key != IssueStatusKeys.Resolved).Any(); + } + + public IEnumerable Organizations { get; } + + public bool HasPendingRequests { get; } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/ListCertificateItemViewModel.cs b/src/NuGetGallery/ViewModels/ListCertificateItemViewModel.cs new file mode 100644 index 0000000000..adb32ac690 --- /dev/null +++ b/src/NuGetGallery/ViewModels/ListCertificateItemViewModel.cs @@ -0,0 +1,26 @@ +// 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 +{ + public sealed class ListCertificateItemViewModel + { + public string Sha1Thumbprint { get; } + public bool CanDelete { get; } + public string DeleteUrl { get; } + + public ListCertificateItemViewModel(Certificate certificate, string deleteUrl) + { + if (certificate == null) + { + throw new ArgumentNullException(nameof(certificate)); + } + + Sha1Thumbprint = certificate.Sha1Thumbprint; + CanDelete = !string.IsNullOrEmpty(deleteUrl); + DeleteUrl = deleteUrl; + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/ListPackageItemRequiredSignerViewModel.cs b/src/NuGetGallery/ViewModels/ListPackageItemRequiredSignerViewModel.cs new file mode 100644 index 0000000000..80a8bac8ec --- /dev/null +++ b/src/NuGetGallery/ViewModels/ListPackageItemRequiredSignerViewModel.cs @@ -0,0 +1,190 @@ +// 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 System.Text; +using NuGetGallery.Security; + +namespace NuGetGallery +{ + public sealed class ListPackageItemRequiredSignerViewModel : ListPackageItemViewModel + { + // username must be an empty string because -
+
@@ -40,7 +40,7 @@
-
+
}
-
-