Skip to content
Draft
7 changes: 6 additions & 1 deletion src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Exceptionless.Core.Models;

public record User : IIdentity, IHaveDates, IValidatableObject
public record User : IIdentity, IHaveDates, IVersioned, IValidatableObject
{
/// <summary>
/// Unique id that identifies an user.
Expand Down Expand Up @@ -54,6 +54,11 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public string? VerifyEmailAddressToken { get; set; }
public DateTime VerifyEmailAddressTokenExpiration { get; set; }

/// <summary>
/// Repository concurrency metadata used to reject stale writes, preserved in cached snapshots.
/// </summary>
public string Version { get; set; } = null!;

/// <summary>
/// Gets or sets the users active state.
/// </summary>
Expand Down
45 changes: 45 additions & 0 deletions src/Exceptionless.Core/Repositories/UserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Exceptionless.Core.Repositories.Configuration;
using Exceptionless.Core.Validation;
using Foundatio.Repositories;
using Foundatio.Repositories.Exceptions;
using Foundatio.Repositories.Models;
using Foundatio.Repositories.Options;
using User = Exceptionless.Core.Models.User;
Expand All @@ -19,6 +20,44 @@ public UserRepository(ExceptionlessElasticConfiguration configuration, MiniValid
AddRequiredField(u => u.EmailAddress, u => u.OrganizationIds);
}

public override Task SaveAsync(IEnumerable<User> documents, ICommandOptions? options = null)
{
ArgumentNullException.ThrowIfNull(documents);

var users = documents.ToArray();
if (users.Any(user => user is null))
throw new ArgumentException("Users cannot contain null values.", nameof(documents));

if (users.Any(user => String.IsNullOrWhiteSpace(user.Version)))
throw new VersionConflictDocumentException("Read the current user record before saving changes.");

return base.SaveAsync(users, options);
}

public override async Task<User?> GetByIdAsync(Id id, ICommandOptions? options = null)
{
var user = await base.GetByIdAsync(id, options);
if (user is null || !String.IsNullOrWhiteSpace(user.Version))
return user;

// Replace legacy cached snapshots, never attach a current version to stale fields.
await InvalidateCacheAsync(user);
return await base.GetByIdAsync(id, new CommandOptions<User>().Cache(false));
}

public override async Task<IReadOnlyCollection<User>> GetByIdsAsync(Ids ids, ICommandOptions? options = null)
{
var users = await base.GetByIdsAsync(ids, options);
var legacyUsers = users.Where(user => String.IsNullOrWhiteSpace(user.Version)).ToArray();
if (legacyUsers.Length == 0)
return users;

foreach (var user in legacyUsers)
await InvalidateCacheAsync(user);

return await base.GetByIdsAsync(ids, new CommandOptions<User>().Cache(false));
}

public async Task<User?> RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc)
{
const string script = """
Expand Down Expand Up @@ -111,6 +150,12 @@ public Task<bool> SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor<Us

emailAddress = emailAddress.Trim().ToLowerInvariant();
var hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), o => o.Cache(EmailCacheKey(emailAddress)));
if (hit?.Document is { } user && String.IsNullOrWhiteSpace(user.Version))
{
await InvalidateCacheAsync(user);
hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), o => o.Cache(false));
}

return hit?.Document;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.Unicode;
using Exceptionless.Core.Models;

namespace Exceptionless.Core.Serialization;

Expand Down Expand Up @@ -35,6 +36,16 @@ public static JsonSerializerOptions ConfigureExceptionlessDefaults(this JsonSeri
public static JsonSerializerOptions ConfigureExceptionlessApiDefaults(this JsonSerializerOptions options)
{
ConfigureExceptionlessDefaults(options, skipEmptyCollections: false);
options.TypeInfoResolver = options.TypeInfoResolver!.WithAddedModifier(typeInfo =>
{
if (typeInfo.Type != typeof(User))
return;

var version = typeInfo.Properties.FirstOrDefault(property => String.Equals(property.Name, "version", StringComparison.Ordinal));
if (version is not null)
typeInfo.Properties.Remove(version);
});

return options;
}

Expand Down
18 changes: 18 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,15 @@ headers api_key input box.
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign in with GitHub")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["200"] = "User Authentication Token",
["401"] = "Sign-in failed",
["403"] = "Account Creation is currently disabled",
["409"] = "The account changed while the request was being processed.",
["422"] = "Validation error",
}
});
Expand All @@ -126,13 +128,15 @@ headers api_key input box.
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign in with Google")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["200"] = "User Authentication Token",
["401"] = "Sign-in failed",
["403"] = "Account Creation is currently disabled",
["409"] = "The account changed while the request was being processed.",
["422"] = "Validation error",
}
});
Expand All @@ -146,13 +150,15 @@ headers api_key input box.
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign in with Facebook")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["200"] = "User Authentication Token",
["401"] = "Sign-in failed",
["403"] = "Account Creation is currently disabled",
["409"] = "The account changed while the request was being processed.",
["422"] = "Validation error",
}
});
Expand All @@ -166,13 +172,15 @@ headers api_key input box.
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Sign in with Microsoft")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["200"] = "User Authentication Token",
["401"] = "Sign-in failed",
["403"] = "Account Creation is currently disabled",
["409"] = "The account changed while the request was being processed.",
["422"] = "Validation error",
}
});
Expand All @@ -182,6 +190,7 @@ headers api_key input box.
.Accepts<ValueFromBody<string>>("application/json", "application/*+json")
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.WithSummary("Removes an external login provider from the account")
.WithMetadata(new EndpointDocumentation {
RequestBodyDescription = "The provider user id.",
Expand All @@ -191,6 +200,7 @@ headers api_key input box.
ResponseDescriptions = new() {
["200"] = "User Authentication Token",
["400"] = "Invalid provider name.",
["409"] = "The account changed while the request was being processed.",
}
});

Expand All @@ -201,10 +211,12 @@ headers api_key input box.
.Accepts<ChangePasswordModel>("application/json", "application/*+json")
.Produces<TokenResult>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status409Conflict)
.WithSummary("Change password")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["200"] = "User Authentication Token",
["409"] = "The account changed while the request was being processed.",
["422"] = "Validation error",
}
});
Expand All @@ -219,6 +231,7 @@ headers api_key input box.
.AllowAnonymous()
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.WithSummary("Forgot password")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
Expand All @@ -227,6 +240,7 @@ headers api_key input box.
ResponseDescriptions = new() {
["200"] = "Forgot password email was sent.",
["400"] = "Invalid email address.",
["409"] = "The account changed while the request was being processed.",
}
});

Expand All @@ -238,10 +252,12 @@ headers api_key input box.
.Accepts<ResetPasswordModel>("application/json", "application/*+json")
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status409Conflict)
.WithSummary("Reset password")
.WithMetadata(new EndpointDocumentation {
ResponseDescriptions = new() {
["200"] = "Password reset email was sent.",
["409"] = "The account changed while the request was being processed.",
["422"] = "Invalid reset password model.",
}
});
Expand All @@ -257,6 +273,7 @@ headers api_key input box.
.AllowAnonymous()
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.WithSummary("Cancel reset password")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
Expand All @@ -265,6 +282,7 @@ headers api_key input box.
ResponseDescriptions = new() {
["200"] = "Password reset email was cancelled.",
["400"] = "Invalid password reset token.",
["409"] = "The account changed while the request was being processed.",
}
});

Expand Down
52 changes: 50 additions & 2 deletions src/Exceptionless.Web/Api/Handlers/AuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -563,15 +563,22 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, string? invit
{
var currentUser = httpContext.Request.GetUser();
EnsureUserIsActive(currentUser);
OAuthAccount? removedAccount = null;
string? removedAccountVersion = null;

if (existingUser is not null)
{
if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal))
{
removedAccount = existingUser.OAuthAccounts.FirstOrDefault(account => String.Equals(account.Provider, userInfo.ProviderName, StringComparison.OrdinalIgnoreCase)
&& String.Equals(account.ProviderUserId, userInfo.Id, StringComparison.Ordinal));

if (!existingUser.RemoveOAuthAccount(userInfo.ProviderName, userInfo.Id))
throw new Exception($"Unable to remove existing oauth account for existing user: {existingUser.EmailAddress}");

existingUser.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken();
await userRepository.SaveAsync(existingUser, o => o.Cache());
existingUser = await userRepository.SaveAsync(existingUser, o => o.Cache());
removedAccountVersion = existingUser.Version;
await ResetUserTokensAsync(existingUser, "Link external account to another user", httpContext);
}
else
Expand All @@ -581,7 +588,18 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, string? invit
}

currentUser.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email);
return await userRepository.SaveAsync(currentUser, o => o.Cache());

try
{
return await userRepository.SaveAsync(currentUser, o => o.Cache());
}
catch
{
if (existingUser is not null && removedAccount is not null)
await RestoreTransferredExternalLoginAsync(existingUser, removedAccountVersion, removedAccount, currentUser.Id);

throw;
}
}

if (existingUser is not null)
Expand Down Expand Up @@ -637,6 +655,36 @@ private async Task<bool> IsAccountCreationEnabledAsync(string? token)
return organization is not null;
}

private async Task RestoreTransferredExternalLoginAsync(User previousOwner, string? removedAccountVersion, OAuthAccount account, string destinationUserId)
{
try
{
// A failed save may have committed before a cache or transport error.
var destination = await userRepository.GetByIdAsync(destinationUserId);
if (destination is not null && destination.OAuthAccounts.Any(existing => String.Equals(existing.Provider, account.Provider, StringComparison.Ordinal)
&& String.Equals(existing.ProviderUserId, account.ProviderUserId, StringComparison.Ordinal)))
return;

var currentOwner = await userRepository.GetByIdAsync(previousOwner.Id);
if (currentOwner is null || String.IsNullOrEmpty(removedAccountVersion)
|| !String.Equals(currentOwner.Version, removedAccountVersion, StringComparison.Ordinal)
|| !String.Equals(currentOwner.AuthenticationVersion, previousOwner.AuthenticationVersion, StringComparison.Ordinal)
|| currentOwner.OAuthAccounts.Any(existing => String.Equals(existing.Provider, account.Provider, StringComparison.Ordinal)
&& String.Equals(existing.ProviderUserId, account.ProviderUserId, StringComparison.Ordinal)))
{
logger.LogWarning("External account restoration skipped for {EmailAddress} ({UserId}) because the previous owner changed", previousOwner.EmailAddress, previousOwner.Id);
return;
}

currentOwner.OAuthAccounts.Add(account);
await userRepository.SaveAsync(currentOwner, o => o.Cache());
}
catch (Exception ex)
{
logger.LogCritical(ex, "Error restoring external account for {EmailAddress} ({UserId}): {Message}", previousOwner.EmailAddress, previousOwner.Id, ex.Message);
}
}

private async Task AddInvitedUserToOrganizationAsync(string? token, User user, HttpContext httpContext)
{
if (String.IsNullOrWhiteSpace(token))
Expand Down
Loading
Loading