/ GUNRPG.Infrastructure / Identity / AccountIdProvisioning.cs
AccountIdProvisioning.cs
  1  using System.Collections.Concurrent;
  2  using LiteDB;
  3  using Microsoft.AspNetCore.Identity;
  4  
  5  namespace GUNRPG.Infrastructure.Identity;
  6  
  7  /// <summary>
  8  /// Ensures authenticated users have a stable account identifier for operator ownership isolation.
  9  /// </summary>
 10  public static class AccountIdProvisioning
 11  {
 12      private static readonly ConcurrentDictionary<string, UserLock> UserLocks = new(StringComparer.Ordinal);
 13  
 14      public static async Task<IdentityResult> EnsureAssignedAsync(
 15          UserManager<ApplicationUser> userManager,
 16          ApplicationUser user,
 17          CancellationToken ct = default)
 18      {
 19          if (user.AccountId is { } accountId && accountId != Guid.Empty)
 20              return IdentityResult.Success;
 21  
 22          using (await AcquireUserLockAsync(user.Id, ct))
 23          {
 24              var storedUser = await userManager.FindByIdAsync(user.Id);
 25              if (storedUser is null)
 26              {
 27                  return IdentityResult.Failed(new IdentityError
 28                  {
 29                      Code = "UserNotFound",
 30                      Description = "The user no longer exists in the identity store.",
 31                  });
 32              }
 33  
 34              if (storedUser.AccountId is { } storedAccountId && storedAccountId != Guid.Empty)
 35              {
 36                  user.AccountId = storedAccountId;
 37                  return IdentityResult.Success;
 38              }
 39  
 40              storedUser.AccountId = Guid.NewGuid();
 41              var result = await userManager.UpdateAsync(storedUser);
 42  
 43              if (result.Succeeded && storedUser.AccountId is { } newAccountId && newAccountId != Guid.Empty)
 44              {
 45                  user.AccountId = newAccountId;
 46                  return result;
 47              }
 48  
 49              var reloadedUser = await userManager.FindByIdAsync(user.Id);
 50              if (reloadedUser?.AccountId is { } reloadedAccountId && reloadedAccountId != Guid.Empty)
 51              {
 52                  user.AccountId = reloadedAccountId;
 53                  return IdentityResult.Success;
 54              }
 55  
 56              return result;
 57          }
 58      }
 59  
 60      public static async Task<(IdentityResult Result, Guid? AccountId)> EnsureAssignedAsync(
 61          ILiteCollection<ApplicationUser> users,
 62          string userId,
 63          CancellationToken ct = default)
 64      {
 65          using (await AcquireUserLockAsync(userId, ct))
 66          {
 67              var storedUser = users.FindById(userId);
 68              if (storedUser is null)
 69              {
 70                  return (
 71                      IdentityResult.Failed(new IdentityError
 72                      {
 73                          Code = "UserNotFound",
 74                          Description = "The user no longer exists in the identity store.",
 75                      }),
 76                      null);
 77              }
 78  
 79              if (storedUser.AccountId is { } storedAccountId && storedAccountId != Guid.Empty)
 80                  return (IdentityResult.Success, storedAccountId);
 81  
 82              storedUser.AccountId = Guid.NewGuid();
 83              if (users.Update(storedUser) && storedUser.AccountId is { } newAccountId && newAccountId != Guid.Empty)
 84                  return (IdentityResult.Success, newAccountId);
 85  
 86              var reloadedUser = users.FindById(userId);
 87              if (reloadedUser?.AccountId is { } reloadedAccountId && reloadedAccountId != Guid.Empty)
 88                  return (IdentityResult.Success, reloadedAccountId);
 89  
 90              return (
 91                  IdentityResult.Failed(new IdentityError
 92                  {
 93                      Code = "UserUpdateFailed",
 94                      Description = "Failed to assign an account ID to the user.",
 95                  }),
 96                  null);
 97          }
 98      }
 99  
100      private static async Task<IDisposable> AcquireUserLockAsync(string userId, CancellationToken ct)
101      {
102          while (true)
103          {
104              var userLock = UserLocks.GetOrAdd(userId, static _ => new UserLock());
105              Interlocked.Increment(ref userLock.RefCount);
106  
107              if (UserLocks.TryGetValue(userId, out var currentLock) && ReferenceEquals(userLock, currentLock))
108              {
109                  await userLock.Gate.WaitAsync(ct);
110                  return new Releaser(userId, userLock);
111              }
112  
113              Interlocked.Decrement(ref userLock.RefCount);
114          }
115      }
116  
117      private sealed class UserLock
118      {
119          public SemaphoreSlim Gate { get; } = new(1, 1);
120          public volatile int RefCount;
121      }
122  
123      private readonly struct Releaser(string userId, UserLock userLock) : IDisposable
124      {
125          public void Dispose()
126          {
127              userLock.Gate.Release();
128              if (Interlocked.Decrement(ref userLock.RefCount) == 0)
129                  UserLocks.TryRemove(new KeyValuePair<string, UserLock>(userId, userLock));
130          }
131      }
132  }