Identity uses email address as the compare/exchange key.
This commit is contained in:
@@ -164,6 +164,7 @@ namespace Raven.Identity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("This has been replaced with RavenDB compare/exchange values which work cluster-wide.")]
|
||||||
public sealed class IdentityUserByUserName
|
public sealed class IdentityUserByUserName
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -19,10 +19,10 @@
|
|||||||
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
|
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
|
||||||
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
|
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
|
||||||
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
|
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
|
||||||
<Version>5.0.1</Version>
|
<Version>6.0.1</Version>
|
||||||
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
|
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
|
||||||
<RootNamespace>Raven.Identity</RootNamespace>
|
<RootNamespace>Raven.Identity</RootNamespace>
|
||||||
<PackageReleaseNotes>Configuration of doc store before initialization.</PackageReleaseNotes>
|
<PackageReleaseNotes>Compare/exchange to enforce unique emails cluster-wide, storing with email address as the key.</PackageReleaseNotes>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+132
-15
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Raven.Client.Documents;
|
using Raven.Client.Documents;
|
||||||
|
using Raven.Client.Documents.Operations;
|
||||||
|
using Raven.Client.Documents.Operations.CompareExchange;
|
||||||
using Raven.Client.Documents.Session;
|
using Raven.Client.Documents.Session;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -35,6 +37,8 @@ namespace Raven.Identity
|
|||||||
private readonly Func<IAsyncDocumentSession> getSessionFunc;
|
private readonly Func<IAsyncDocumentSession> getSessionFunc;
|
||||||
private IAsyncDocumentSession _session;
|
private IAsyncDocumentSession _session;
|
||||||
|
|
||||||
|
private const string emailReservationKeyPrefix = "emails/";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new user store that uses the Raven document session returned from the specified session fetcher.
|
/// Creates a new user store that uses the Raven document session returned from the specified session fetcher.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -114,6 +118,13 @@ namespace Raven.Identity
|
|||||||
public async Task<IdentityResult> CreateAsync(TUser user, CancellationToken cancellationToken)
|
public async Task<IdentityResult> CreateAsync(TUser user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ThrowIfNullDisposedCancelled(user, 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))
|
if (string.IsNullOrEmpty(user.Id))
|
||||||
{
|
{
|
||||||
var conventions = DbSession.Advanced.DocumentStore.Conventions;
|
var conventions = DbSession.Advanced.DocumentStore.Conventions;
|
||||||
@@ -123,12 +134,46 @@ namespace Raven.Identity
|
|||||||
user.Id = id;
|
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();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var userByName = new IdentityUserByUserName(user.Id, user.UserName);
|
// See if the email address is already taken.
|
||||||
await DbSession.StoreAsync(userByName, Util.GetIdentityUserByUserNameId(user.UserName));
|
// 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<string>(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;
|
return IdentityResult.Success;
|
||||||
}
|
}
|
||||||
@@ -137,7 +182,7 @@ namespace Raven.Identity
|
|||||||
public Task<IdentityResult> UpdateAsync(TUser user, CancellationToken cancellationToken)
|
public Task<IdentityResult> UpdateAsync(TUser user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ThrowIfNullDisposedCancelled(user, cancellationToken);
|
ThrowIfNullDisposedCancelled(user, cancellationToken);
|
||||||
|
|
||||||
return Task.FromResult(IdentityResult.Success);
|
return Task.FromResult(IdentityResult.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,16 +190,27 @@ namespace Raven.Identity
|
|||||||
public async Task<IdentityResult> DeleteAsync(TUser user, CancellationToken cancellationToken)
|
public async Task<IdentityResult> DeleteAsync(TUser user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ThrowIfNullDisposedCancelled(user, cancellationToken);
|
ThrowIfNullDisposedCancelled(user, cancellationToken);
|
||||||
|
|
||||||
var userByName = await DbSession.LoadAsync<IdentityUserByUserName>(Util.GetIdentityUserByUserNameId(user.UserName));
|
|
||||||
if (userByName != null)
|
|
||||||
{
|
|
||||||
DbSession.Delete(userByName);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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);
|
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;
|
return IdentityResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,15 +231,18 @@ namespace Raven.Identity
|
|||||||
throw new ArgumentNullException(nameof(normalizedUserName));
|
throw new ArgumentNullException(nameof(normalizedUserName));
|
||||||
}
|
}
|
||||||
|
|
||||||
var userByName = await DbSession.LoadAsync<IdentityUserByUserName>(Util.GetIdentityUserByUserNameId(normalizedUserName));
|
var compareExchangeKey = GetCompareExchangeKeyFromEmail(normalizedUserName);
|
||||||
if (userByName == null)
|
var getEmailReservationOperation = new GetCompareExchangeValueOperation<string>(compareExchangeKey);
|
||||||
|
var emailReservationResultOrNull = await DbSession.Advanced.DocumentStore.Operations.SendAsync(getEmailReservationOperation);
|
||||||
|
var userId = emailReservationResultOrNull?.Value;
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
return await FindByIdAsync(userByName.UserId, cancellationToken);
|
return await FindByIdAsync(userId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -732,6 +791,52 @@ namespace Raven.Identity
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
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<IdentityUserByUserName>("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<string>(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
|
private IAsyncDocumentSession DbSession
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -765,5 +870,17 @@ namespace Raven.Identity
|
|||||||
}
|
}
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task<CompareExchangeResult<string>> DeleteUserEmailReservation(string email)
|
||||||
|
{
|
||||||
|
var key = GetCompareExchangeKeyFromEmail(email);
|
||||||
|
var deleteEmailOperation = new DeleteCompareExchangeValueOperation<string>(key, 0);
|
||||||
|
return DbSession.Advanced.DocumentStore.Operations.SendAsync(deleteEmailOperation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCompareExchangeKeyFromEmail(string email)
|
||||||
|
{
|
||||||
|
return emailReservationKeyPrefix + email.ToLowerInvariant();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,17 @@ namespace Raven.Identity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string GetIdentityUserByUserNameId(string userName)
|
//internal static int GetDeterministicHashCode(string input)
|
||||||
{
|
//{
|
||||||
return string.Format("IdentityUserByUserNames/{0}", userName);
|
// unchecked
|
||||||
}
|
// {
|
||||||
|
// int hash = 23;
|
||||||
|
// foreach (char c in input)
|
||||||
|
// {
|
||||||
|
// hash = hash * 31 + c;
|
||||||
|
// }
|
||||||
|
// return hash;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ namespace Sample
|
|||||||
|
|
||||||
// Create our database if it doesn't exist yet.
|
// Create our database if it doesn't exist yet.
|
||||||
app.ApplicationServices.GetRequiredService<IDocumentStore>().EnsureExists();
|
app.ApplicationServices.GetRequiredService<IDocumentStore>().EnsureExists();
|
||||||
|
|
||||||
|
// Did you store users prior to RavenDB.Identity v6? If so, you need to call MigrateToV6.
|
||||||
|
// Raven.Identity.UserStore<AppUser>.MigrateToV6(app.ApplicationServices.GetRequiredService<IDocumentStore>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user