diff --git a/RavenDB.Identity/IdentityUser.cs b/RavenDB.Identity/IdentityUser.cs index c39d8ce..5af2122 100644 --- a/RavenDB.Identity/IdentityUser.cs +++ b/RavenDB.Identity/IdentityUser.cs @@ -164,6 +164,7 @@ namespace Raven.Identity /// /// Entity that aides in helping us load users by a well-known name directly from the RavenDB ACID storage engine, bypassing the eventually consistent RavenDB indexes. /// + [Obsolete("This has been replaced with RavenDB compare/exchange values which work cluster-wide.")] public sealed class IdentityUserByUserName { /// diff --git a/RavenDB.Identity/RavenDB.Identity.csproj b/RavenDB.Identity/RavenDB.Identity.csproj index 8c3b7b6..d7b1597 100644 --- a/RavenDB.Identity/RavenDB.Identity.csproj +++ b/RavenDB.Identity/RavenDB.Identity.csproj @@ -19,10 +19,10 @@ false false false - 5.0.1 + 6.0.1 False Raven.Identity - Configuration of doc store before initialization. + Compare/exchange to enforce unique emails cluster-wide, storing with email address as the key. diff --git a/RavenDB.Identity/UserStore.cs b/RavenDB.Identity/UserStore.cs index a1eb921..cafd231 100644 --- a/RavenDB.Identity/UserStore.cs +++ b/RavenDB.Identity/UserStore.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Identity; using Raven.Client.Documents; +using Raven.Client.Documents.Operations; +using Raven.Client.Documents.Operations.CompareExchange; using Raven.Client.Documents.Session; using System; using System.Collections.Generic; @@ -35,6 +37,8 @@ namespace Raven.Identity private readonly Func getSessionFunc; private IAsyncDocumentSession _session; + private const string emailReservationKeyPrefix = "emails/"; + /// /// Creates a new user store that uses the Raven document session returned from the specified session fetcher. /// @@ -114,6 +118,13 @@ namespace Raven.Identity public async Task CreateAsync(TUser user, CancellationToken cancellationToken) { ThrowIfNullDisposedCancelled(user, cancellationToken); + + // Make sure we have a valid email address. + if (string.IsNullOrWhiteSpace(user.Email)) + { + throw new ArgumentException("The user's email address can't be null or empty.", nameof(user)); + } + if (string.IsNullOrEmpty(user.Id)) { var conventions = DbSession.Advanced.DocumentStore.Conventions; @@ -123,12 +134,46 @@ namespace Raven.Identity user.Id = id; } - // This model allows us to lookup a user by name in order to get the id - await DbSession.StoreAsync(user, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - var userByName = new IdentityUserByUserName(user.Id, user.UserName); - await DbSession.StoreAsync(userByName, Util.GetIdentityUserByUserNameId(user.UserName)); + // See if the email address is already taken. + // We do this using Raven's compare/exchange functionality, which works cluster-wide. + // https://ravendb.net/docs/article-page/4.1/csharp/client-api/operations/compare-exchange/overview#creating-a-key + // + // Try to reserve a new user email + // Note: This operation takes place outside of the session transaction it is a cluster-wide reservation. + var compareExchangeKey = GetCompareExchangeKeyFromEmail(user.Email); + var reserveEmailOperation = new PutCompareExchangeValueOperation(compareExchangeKey, user.Id, 0); + var reserveEmailResult = await DbSession.Advanced.DocumentStore.Operations.SendAsync(reserveEmailOperation); + if (!reserveEmailResult.Successful) + { + return IdentityResult.Failed(new[] + { + new IdentityError + { + Code = "DuplicateEmail", + Description = $"The email address {user.Email} is already taken." + } + }); + } + + // This model allows us to lookup a user by name in order to get the id + await DbSession.StoreAsync(user, cancellationToken); + + // Because this a a cluster-wide operation due to compare/exchange tokens, + // we need to save changes here; if we can't store the user, + // we need to roll back the email reservation. + try + { + await DbSession.SaveChangesAsync(); + } + catch (Exception) + { + // The compare/exchange email reservation is cluster-wide, outside of the session scope. + // We need to manually roll it back. + await this.DeleteUserEmailReservation(user.Email); + throw; + } return IdentityResult.Success; } @@ -137,7 +182,7 @@ namespace Raven.Identity public Task UpdateAsync(TUser user, CancellationToken cancellationToken) { ThrowIfNullDisposedCancelled(user, cancellationToken); - + return Task.FromResult(IdentityResult.Success); } @@ -145,16 +190,27 @@ namespace Raven.Identity public async Task DeleteAsync(TUser user, CancellationToken cancellationToken) { ThrowIfNullDisposedCancelled(user, cancellationToken); - - var userByName = await DbSession.LoadAsync(Util.GetIdentityUserByUserNameId(user.UserName)); - if (userByName != null) - { - DbSession.Delete(userByName); - } - cancellationToken.ThrowIfCancellationRequested(); + // Delete the user and save it. We must save it because deleting is a cluster-wide operation. + // Only if the deletion succeeds will we remove the cluseter-wide compare/exchange key. this.DbSession.Delete(user); + await this.DbSession.SaveChangesAsync(); + + // Remove the cluster-wide compare/exchange key. + var deletionResult = await DeleteUserEmailReservation(user.Email); + if (!deletionResult.Successful) + { + return IdentityResult.Failed(new[] + { + new IdentityError + { + Code = "ConcurrencyFailure", + Description = "Unable to delete user email compare/exchange value" + } + }); + } + return IdentityResult.Success; } @@ -175,15 +231,18 @@ namespace Raven.Identity throw new ArgumentNullException(nameof(normalizedUserName)); } - var userByName = await DbSession.LoadAsync(Util.GetIdentityUserByUserNameId(normalizedUserName)); - if (userByName == null) + var compareExchangeKey = GetCompareExchangeKeyFromEmail(normalizedUserName); + var getEmailReservationOperation = new GetCompareExchangeValueOperation(compareExchangeKey); + var emailReservationResultOrNull = await DbSession.Advanced.DocumentStore.Operations.SendAsync(getEmailReservationOperation); + var userId = emailReservationResultOrNull?.Value; + if (string.IsNullOrEmpty(userId)) { return null; } cancellationToken.ThrowIfCancellationRequested(); - return await FindByIdAsync(userByName.UserId, cancellationToken); + return await FindByIdAsync(userId, cancellationToken); } #endregion @@ -732,6 +791,52 @@ namespace Raven.Identity #endregion + /// + /// Migrates the data model from a previous version of the RavenDB.Identity framework to the v6 model. + /// This is necessary if you stored users using an RavenDB.Identity version 5 or ealier. + /// + /// + public static void MigrateToV6(IDocumentStore docStore) + { + // Step 1: find all the old IdentityByUserName objects. + var emails = new List<(string userId, string email)>(1000); + using (var dbSession = docStore.OpenSession()) + { +#pragma warning disable CS0618 // Type or member is obsolete + var stream = dbSession.Advanced.Stream("IdentityUserByUserNames/"); +#pragma warning restore CS0618 // Type or member is obsolete + while (stream.MoveNext()) + { + var doc = stream.Current.Document; + emails.Add((userId: doc.UserId, email: doc.UserName)); + } + } + + // Step 2: store each email as a cluster-wide compare/exchange value. + foreach (var (userId, email) in emails) + { + var compareExchangeKey = GetCompareExchangeKeyFromEmail(email); + var storeOperation = new PutCompareExchangeValueOperation(compareExchangeKey, userId, 0); + var storeResult = docStore.Operations.Send(storeOperation); + if (!storeResult.Successful) + { + var exception = new Exception($"Unable to migrate to RavenDB.Identity V6. An error occurred while storing the compare/exchange value. Before running this {nameof(MigrateToV6)} again, please delete all compare/exchange values in Raven that begin with {emailReservationKeyPrefix}."); + exception.Data.Add("compareExchangeKey", compareExchangeKey); + exception.Data.Add("compareExchangeValue", userId); + throw exception; + } + } + + // Step 3: remove all IdentityUserByUserName objects. + var operation = docStore + .Operations + .Send(new DeleteByQueryOperation(new Client.Documents.Queries.IndexQuery + { + Query = "from IdentityUserByUserNames" + })); + operation.WaitForCompletion(); + } + private IAsyncDocumentSession DbSession { get @@ -765,5 +870,17 @@ namespace Raven.Identity } token.ThrowIfCancellationRequested(); } + + private Task> DeleteUserEmailReservation(string email) + { + var key = GetCompareExchangeKeyFromEmail(email); + var deleteEmailOperation = new DeleteCompareExchangeValueOperation(key, 0); + return DbSession.Advanced.DocumentStore.Operations.SendAsync(deleteEmailOperation); + } + + private static string GetCompareExchangeKeyFromEmail(string email) + { + return emailReservationKeyPrefix + email.ToLowerInvariant(); + } } } diff --git a/RavenDB.Identity/Util.cs b/RavenDB.Identity/Util.cs index edeb375..a510789 100644 --- a/RavenDB.Identity/Util.cs +++ b/RavenDB.Identity/Util.cs @@ -53,9 +53,17 @@ namespace Raven.Identity } } - internal static string GetIdentityUserByUserNameId(string userName) - { - return string.Format("IdentityUserByUserNames/{0}", userName); - } + //internal static int GetDeterministicHashCode(string input) + //{ + // unchecked + // { + // int hash = 23; + // foreach (char c in input) + // { + // hash = hash * 31 + c; + // } + // return hash; + // } + //} } } diff --git a/Sample/Startup.cs b/Sample/Startup.cs index e8e4f83..0b042e6 100644 --- a/Sample/Startup.cs +++ b/Sample/Startup.cs @@ -86,6 +86,9 @@ namespace Sample // Create our database if it doesn't exist yet. app.ApplicationServices.GetRequiredService().EnsureExists(); + + // Did you store users prior to RavenDB.Identity v6? If so, you need to call MigrateToV6. + // Raven.Identity.UserStore.MigrateToV6(app.ApplicationServices.GetRequiredService()); } } }