Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin panel for adding/remove user email address for password authentication. #9468

Merged
merged 17 commits into from
May 12, 2023
7 changes: 4 additions & 3 deletions src/NuGetGallery.Core/Auditing/FeatureFlagsAuditRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using NuGet.Services.FeatureFlags;
using NuGetGallery.Auditing.AuditedEntities;
using NuGetGallery.Features;
using NuGetGallery.Shared;

namespace NuGetGallery.Auditing
{
Expand All @@ -13,13 +14,13 @@ public sealed class FeatureFlagsAuditRecord : AuditRecord<AuditedFeatureFlagsAct
public AuditedFeatureFlagFeature[] Features { get; }
public AuditedFeatureFlagFlight[] Flights { get; }
public string ContentId { get; }
public FeatureFlagSaveResult Result { get; }
public ContentSaveResult Result { get; }

public FeatureFlagsAuditRecord(
AuditedFeatureFlagsAction action,
FeatureFlags flags,
string contentId,
FeatureFlagSaveResult result)
string contentId,
ContentSaveResult result)
: base(action)
{
if (flags == null)
Expand Down
2 changes: 2 additions & 0 deletions src/NuGetGallery.Core/CoreConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,7 @@ public static class Folders
public const string LicenseFileName = "license";

public const string FeatureFlagsFileName = "flags.json";

public const string LoginDiscontinuationConfigFileName = "Login-Discontinuation-Configuration.json";
}
}
5 changes: 5 additions & 0 deletions src/NuGetGallery.Core/CredentialTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,10 @@ public static Credential GetAzureActiveDirectoryCredential(this ICollection<Cred
{
return credentials.SingleOrDefault(c => IsAzureActiveDirectoryAccount(c.Type));
}

public static Credential GetMicrosoftAccountCredential(this ICollection<Credential> credentials)
{
return credentials.SingleOrDefault(c => IsMicrosoftAccount(c.Type));
joelverhagen marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using NuGet.Services.Entities;
using NuGet.Services.FeatureFlags;
using NuGetGallery.Auditing;
using NuGetGallery.Shared;

namespace NuGetGallery.Features
{
Expand Down Expand Up @@ -69,7 +70,7 @@ public async Task RemoveUserAsync(User user)
f => RemoveUser(f.Value, user)));

var saveResult = await TrySaveAsync(result, reference.ContentId);
if (saveResult == FeatureFlagSaveResult.Ok)
if (saveResult == ContentSaveResult.Ok)
{
return;
}
Expand All @@ -84,7 +85,7 @@ public async Task RemoveUserAsync(User user)
throw new InvalidOperationException($"Unable to remove user from feature flags after {MaxRemoveUserAttempts} attempts");
}

public async Task<FeatureFlagSaveResult> TrySaveAsync(FeatureFlags flags, string contentId)
public async Task<ContentSaveResult> TrySaveAsync(FeatureFlags flags, string contentId)
{
var result = await TrySaveInternalAsync(flags, contentId);
await _auditing.SaveAuditRecordAsync(
zhhyu marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -97,7 +98,7 @@ await _auditing.SaveAuditRecordAsync(
return result;
}

private async Task<FeatureFlagSaveResult> TrySaveInternalAsync(FeatureFlags flags, string contentId)
private async Task<ContentSaveResult> TrySaveInternalAsync(FeatureFlags flags, string contentId)
{
var accessCondition = AccessConditionWrapper.GenerateIfMatchCondition(contentId);

Expand All @@ -113,12 +114,12 @@ private async Task<FeatureFlagSaveResult> TrySaveInternalAsync(FeatureFlags flag

await _storage.SaveFileAsync(CoreConstants.Folders.ContentFolderName, CoreConstants.FeatureFlagsFileName, stream, accessCondition);

return FeatureFlagSaveResult.Ok;
return ContentSaveResult.Ok;
}
}
catch (StorageException e) when (e.IsPreconditionFailedException())
{
return FeatureFlagSaveResult.Conflict;
return ContentSaveResult.Conflict;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading.Tasks;
using NuGet.Services.Entities;
using NuGet.Services.FeatureFlags;
using NuGetGallery.Shared;

namespace NuGetGallery.Features
{
Expand All @@ -23,7 +24,7 @@ public interface IEditableFeatureFlagStorageService : IFeatureFlagStorageService
/// <param name="flags">The feature flags.</param>
/// <param name="contentId">The feature flag's ETag.</param>
/// <returns>The result of the save operation.</returns>
Task<FeatureFlagSaveResult> TrySaveAsync(FeatureFlags flags, string contentId);
Task<ContentSaveResult> TrySaveAsync(FeatureFlags flags, string contentId);

/// <summary>
/// Remove the user from the feature flags if needed. This may throw on failure.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.WindowsAzure.Storage;
using Newtonsoft.Json;
using NuGetGallery.Features;
using NuGetGallery.Shared;

namespace NuGetGallery.Login
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
{
public class EditableLoginConfigurationFileStorageService: LoginDiscontinuationFileStorageService, IEditableLoginConfigurationFileStorageService
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
{
private const int MaxAttempts = 3;
private readonly ILogger<EditableLoginConfigurationFileStorageService> _logger;

public EditableLoginConfigurationFileStorageService(
ICoreFileStorageService storage,
ILogger<EditableLoginConfigurationFileStorageService> logger) : base(storage)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

public async Task<LoginDiscontinuationReference> GetReferenceAsync()
{
var reference = await _storage.GetFileReferenceAsync(CoreConstants.Folders.ContentFolderName, CoreConstants.LoginDiscontinuationConfigFileName);

return new LoginDiscontinuationReference(
ReadLoginDiscontinuationFromStream(reference.OpenRead()),
reference.ContentId);
}
public async Task AddUserEmailAddressforPasswordAuthenticationAsync(string emailAddress, bool add)
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
{
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
for (var attempt = 0; attempt < MaxAttempts; attempt++)
{
var reference = await _storage.GetFileReferenceAsync(CoreConstants.Folders.ContentFolderName, CoreConstants.LoginDiscontinuationConfigFileName);

LoginDiscontinuation logins;
using (var stream = reference.OpenRead())
using (var streamReader = new StreamReader(stream))
using (var reader = new JsonTextReader(streamReader))
{
logins = _serializer.Deserialize<LoginDiscontinuation>(reader);
}

var exceptionsForEmailAddresses = logins.ExceptionsForEmailAddresses;
if (add)
{

if (logins.ExceptionsForEmailAddresses.Contains(emailAddress))
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
{
return;
}
exceptionsForEmailAddresses.Add(emailAddress);

}
else
{
if (!logins.ExceptionsForEmailAddresses.Contains(emailAddress))

{
return;
}
exceptionsForEmailAddresses.Remove(emailAddress);

lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved

}

var result = new LoginDiscontinuation(
logins.DiscontinuedForEmailAddresses,
logins.DiscontinuedForDomains,
exceptionsForEmailAddresses,
logins.ForceTransformationToOrganizationForEmailAddresses,
logins.EnabledOrganizationAadTenants,
logins.IsPasswordDiscontinuedForAll);


lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
var saveResult = await TrySaveAsync(result, reference.ContentId);
if (saveResult == ContentSaveResult.Ok)
{
return;
}

var operation = add ? "add" : "remove";
_logger.LogWarning(
0,
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
"Failed to {operation} emailAddress from exception list, attempt {Attempt} of {MaxAttempts}...",
operation,
attempt + 1,
MaxAttempts);
}

throw new InvalidOperationException($"Unable to add/remove emailAddress from exception list after {MaxAttempts} attempts");
}

public async Task<ContentSaveResult> TrySaveAsync(LoginDiscontinuation loginDiscontinuation, string contentId)
{
var result = await TrySaveInternalAsync(loginDiscontinuation, contentId);

return result;
}

public async Task<IReadOnlyList<string>> GetListOfExceptionEmailList()
{
for (var attempt = 0; attempt < MaxAttempts; attempt++)
{
var loginDiscontinuation = await GetAsync();

IReadOnlyList<string> result = null;
if (loginDiscontinuation != null) {
result = loginDiscontinuation.ExceptionsForEmailAddresses.ToList();
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
}

return result;
}

throw new InvalidOperationException($"Unable to get list of exception email list from loginDiscontinuationConfig file after {MaxAttempts} attempts");

}

private async Task<ContentSaveResult> TrySaveInternalAsync(LoginDiscontinuation loginDiscontinuationConfig, string contentId)
{
var accessCondition = AccessConditionWrapper.GenerateIfMatchCondition(contentId);

try
{
using (var stream = new MemoryStream())
using (var writer = new StreamWriter(stream))
using (var jsonWriter = new JsonTextWriter(writer))
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
{
_serializer.Serialize(jsonWriter, loginDiscontinuationConfig);
jsonWriter.Flush();
stream.Position = 0;

await _storage.SaveFileAsync(CoreConstants.Folders.ContentFolderName, CoreConstants.LoginDiscontinuationConfigFileName, stream, accessCondition);

return ContentSaveResult.Ok;
}
}
catch (StorageException e) when (e.IsPreconditionFailedException())
{
return ContentSaveResult.Conflict;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using NuGetGallery.Shared;

namespace NuGetGallery.Login
{
public interface IEditableLoginConfigurationFileStorageService: ILoginDiscontinuationFileStorageService
{

/// <summary>
/// Get a reference to the loginDiscontinuation's raw content.
/// </summary>
/// <returns>A snapshot of the loginDiscontinuation's content and ETag.</returns>
Task<LoginDiscontinuationReference> GetReferenceAsync();

/// <summary>
/// Add or Remove an user email address to the excpetion email list on loginDiscontinuation.
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
/// <param name="emailAddress">The user email address.</param>
/// <param name="add">Indicate remove or add email address.</param>
Task AddUserEmailAddressforPasswordAuthenticationAsync(string emailAddress, bool add);

/// <summary>
/// Get an excpetion email list on loginDiscontinuation.
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
Task<IReadOnlyList<string>> GetListOfExceptionEmailList();

/// <summary>
/// Try to update the LoginDiscontinuation.
/// </summary>
/// <param name="loginDiscontinuation">The log in discontinuation configuration.</param>
/// <param name="contentId">The loginDiscontinuation's ETag.</param>
/// <returns>The result of the save operation.</returns>
Task<ContentSaveResult> TrySaveAsync(LoginDiscontinuation loginDiscontinuation, string contentId);

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NuGetGallery.Login
{
public interface ILoginDiscontinuationFileStorageService
{
Task<LoginDiscontinuation> GetAsync();
}
}
70 changes: 70 additions & 0 deletions src/NuGetGallery.Core/Login/LoginDiscontinuation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;

namespace NuGetGallery.Login
{
public class LoginDiscontinuation
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
{
public bool IsPasswordDiscontinuedForAll { get; }
public HashSet<string> DiscontinuedForEmailAddresses { get; set; }
public HashSet<string> DiscontinuedForDomains { get; }
public HashSet<string> ExceptionsForEmailAddresses { get; set; }
public HashSet<string> ForceTransformationToOrganizationForEmailAddresses { get; }
public HashSet<OrganizationTenantPair> EnabledOrganizationAadTenants { get; }

public LoginDiscontinuation(
IEnumerable<string> discontinuedForEmailAddresses,
lyndaidaii marked this conversation as resolved.
Show resolved Hide resolved
IEnumerable<string> discontinuedForDomains,
IEnumerable<string> exceptionsForEmailAddresses,
IEnumerable<string> forceTransformationToOrganizationForEmailAddresses,
IEnumerable<OrganizationTenantPair> enabledOrganizationAadTenants,
bool isPasswordDiscontinuedForAll)
{
DiscontinuedForEmailAddresses = new HashSet<string>(discontinuedForEmailAddresses, StringComparer.OrdinalIgnoreCase);
DiscontinuedForDomains = new HashSet<string>(discontinuedForDomains, StringComparer.OrdinalIgnoreCase);
ExceptionsForEmailAddresses = new HashSet<string>(exceptionsForEmailAddresses, StringComparer.OrdinalIgnoreCase);
ForceTransformationToOrganizationForEmailAddresses = new HashSet<string>(forceTransformationToOrganizationForEmailAddresses, StringComparer.OrdinalIgnoreCase);
EnabledOrganizationAadTenants = new HashSet<OrganizationTenantPair>(enabledOrganizationAadTenants);
IsPasswordDiscontinuedForAll = isPasswordDiscontinuedForAll;
}
}
public class OrganizationTenantPair
{
public string EmailDomain { get; }
public string TenantId { get; }

[JsonConstructor]
public OrganizationTenantPair(string emailDomain, string tenantId)
{
EmailDomain = emailDomain ?? throw new ArgumentNullException(nameof(emailDomain));
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
}

public override bool Equals(object obj)
{
return Equals(obj as OrganizationTenantPair);
}

public bool Equals(OrganizationTenantPair other)
{
return other != null &&
string.Equals(EmailDomain, other.EmailDomain, StringComparison.OrdinalIgnoreCase) &&
string.Equals(TenantId, other.TenantId, StringComparison.OrdinalIgnoreCase);
}

/// <remarks>
/// Autogenerated by "Quick Actions and Refactoring" -> "Generate Equals and GetHashCode".
/// </remarks>
public override int GetHashCode()
{
var hashCode = -1334890813;
hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(EmailDomain.ToLowerInvariant());
hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(TenantId.ToLowerInvariant());
return hashCode;
}
}
}
Loading