mirror of
https://github.com/bitwarden/server.git
synced 2025-04-05 05:00:19 -05:00
feat : fix database script; add comments.
This commit is contained in:
parent
11fcb26778
commit
9848d53683
@ -1,4 +1,6 @@
|
||||
using Bit.Core.Auth.Models.Api.Request.Opaque;
|
||||
using Bit.Api.Auth.Models.Request.Opaque;
|
||||
using Bit.Api.Auth.Models.Response.Opaque;
|
||||
using Bit.Core.Auth.Models.Api.Request.Opaque;
|
||||
using Bit.Core.Auth.Models.Api.Response.Opaque;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Services;
|
||||
@ -42,15 +44,15 @@ public class OpaqueKeyExchangeController : Controller
|
||||
|
||||
// TODO: Remove and move to token endpoint
|
||||
[HttpPost("~/opaque/start-login")]
|
||||
public async Task<Models.Response.Opaque.OpaqueLoginStartResponse> StartLoginAsync([FromBody] Models.Request.Opaque.OpaqueLoginStartRequest request)
|
||||
public async Task<OpaqueLoginStartResponse> StartLoginAsync([FromBody] OpaqueLoginStartRequest request)
|
||||
{
|
||||
var result = await _opaqueKeyExchangeService.StartLogin(Convert.FromBase64String(request.CredentialRequest), request.Email);
|
||||
return new Models.Response.Opaque.OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2));
|
||||
return new OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2));
|
||||
}
|
||||
|
||||
// TODO: Remove and move to token endpoint
|
||||
[HttpPost("~/opaque/finish-login")]
|
||||
public async Task<bool> FinishLoginAsync([FromBody] Models.Request.Opaque.OpaqueLoginFinishRequest request)
|
||||
public async Task<bool> FinishLoginAsync([FromBody] OpaqueLoginFinishRequest request)
|
||||
{
|
||||
var result = await _opaqueKeyExchangeService.FinishLogin(request.SessionId, Convert.FromBase64String(request.CredentialFinalization));
|
||||
return result;
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bitwarden.Opaque;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Api.Request.Opaque;
|
||||
|
||||
@ -8,10 +9,10 @@ public class OpaqueRegistrationStartRequest
|
||||
[Required]
|
||||
public string RegistrationRequest { get; set; }
|
||||
[Required]
|
||||
public CipherConfiguration CipherConfiguration { get; set; }
|
||||
public OpaqueKeyExchangeCipherConfiguration CipherConfiguration { get; set; }
|
||||
}
|
||||
|
||||
public class CipherConfiguration
|
||||
public class OpaqueKeyExchangeCipherConfiguration
|
||||
{
|
||||
static string OpaqueKe3Ristretto3DHArgonSuite = "OPAQUE_3_RISTRETTO255_OPRF_RISTRETTO255_KEGROUP_3DH_KEX_ARGON2ID13_KSF";
|
||||
|
||||
@ -20,20 +21,20 @@ public class CipherConfiguration
|
||||
[Required]
|
||||
public Argon2KsfParameters Argon2Parameters { get; set; }
|
||||
|
||||
public Bitwarden.Opaque.CipherConfiguration ToNativeConfiguration()
|
||||
public CipherConfiguration ToNativeConfiguration()
|
||||
{
|
||||
if (CipherSuite == OpaqueKe3Ristretto3DHArgonSuite)
|
||||
{
|
||||
return new Bitwarden.Opaque.CipherConfiguration
|
||||
return new CipherConfiguration
|
||||
{
|
||||
OpaqueVersion = 3,
|
||||
OprfCs = Bitwarden.Opaque.OprfCs.Ristretto255,
|
||||
KeGroup = Bitwarden.Opaque.KeGroup.Ristretto255,
|
||||
KeyExchange = Bitwarden.Opaque.KeyExchange.TripleDH,
|
||||
Ksf = new Bitwarden.Opaque.Ksf
|
||||
OprfCs = OprfCs.Ristretto255,
|
||||
KeGroup = KeGroup.Ristretto255,
|
||||
KeyExchange = KeyExchange.TripleDH,
|
||||
Ksf = new Ksf
|
||||
{
|
||||
Algorithm = Bitwarden.Opaque.KsfAlgorithm.Argon2id,
|
||||
Parameters = new Bitwarden.Opaque.KsfParameters
|
||||
Algorithm = KsfAlgorithm.Argon2id,
|
||||
Parameters = new KsfParameters
|
||||
{
|
||||
Iterations = Argon2Parameters.Iterations,
|
||||
Memory = Argon2Parameters.Memory,
|
||||
|
@ -4,12 +4,57 @@ using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Auth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that exposes methods enabling the use of the Opaque Key Exchange extension.
|
||||
/// </summary>
|
||||
public interface IOpaqueKeyExchangeService
|
||||
{
|
||||
public Task<OpaqueRegistrationStartResponse> StartRegistration(byte[] request, User user, CipherConfiguration cipherConfiguration);
|
||||
/// <summary>
|
||||
/// Begin registering a user's Opaque Key Exchange Credential. We write to the distributed cache so since there is some back and forth between the client and server.
|
||||
/// </summary>
|
||||
/// <param name="request">unsure what this byte array is for.</param>
|
||||
/// <param name="user">user being acted on</param>
|
||||
/// <param name="cipherConfiguration">configuration shared between the client and server to ensure the proper crypto-algorithms are being utilized.</param>
|
||||
/// <returns>void</returns>
|
||||
public Task<OpaqueRegistrationStartResponse> StartRegistration(byte[] request, User user, OpaqueKeyExchangeCipherConfiguration cipherConfiguration);
|
||||
/// <summary>
|
||||
/// This doesn't actually finish registration. It updates the cache with the server setup and cipher configuration so that the clearly named "SetActive" method can finish registration.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Cache Id</param>
|
||||
/// <param name="registrationUpload">Byte Array for Rust Magic</param>
|
||||
/// <param name="user">User being acted on</param>
|
||||
/// <param name="keyset">Key Pair that can be used for vault decryption</param>
|
||||
/// <returns>void</returns>
|
||||
public Task FinishRegistration(Guid sessionId, byte[] registrationUpload, User user, RotateableOpaqueKeyset keyset);
|
||||
/// <summary>
|
||||
/// Returns server crypto material for the client to consume and reply with a login request to the identity/token endpoint.
|
||||
/// To protect against account enumeration we will always return a deterministic response based on the user's email.
|
||||
/// </summary>
|
||||
/// <param name="request">client crypto material</param>
|
||||
/// <param name="email">user email trying to login</param>
|
||||
/// <returns>tuple(login SessionId for cache lookup, Server crypto material)</returns>
|
||||
public Task<(Guid, byte[])> StartLogin(byte[] request, string email);
|
||||
/// <summary>
|
||||
/// Accepts the client's login request and validates it against the server's crypto material. If successful then the user is logged in.
|
||||
/// If using a fake account we will return a standard failed login. If the account does have a legitimate credential but is still invalid
|
||||
/// we will return a failed login.
|
||||
/// </summary>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="finishCredential"></param>
|
||||
/// <returns></returns>
|
||||
public Task<bool> FinishLogin(Guid sessionId, byte[] finishCredential);
|
||||
/// <summary>
|
||||
/// This is where registration really finishes. This method writes the Credential to the database. If a credential already exists then it will be removed before the new one is added.
|
||||
/// A user can only have one credential.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">cache value</param>
|
||||
/// <param name="user">user being acted on</param>
|
||||
/// <returns>void</returns>
|
||||
public Task SetActive(Guid sessionId, User user);
|
||||
/// <summary>
|
||||
/// Removes the credential for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">user being acted on</param>
|
||||
/// <returns>void</returns>
|
||||
public Task Unenroll(User user);
|
||||
}
|
||||
|
@ -36,12 +36,12 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<OpaqueRegistrationStartResponse> StartRegistration(byte[] request, User user, Models.Api.Request.Opaque.CipherConfiguration cipherConfiguration)
|
||||
public async Task<OpaqueRegistrationStartResponse> StartRegistration(byte[] request, User user, OpaqueKeyExchangeCipherConfiguration cipherConfiguration)
|
||||
{
|
||||
var registrationRequest = _bitwardenOpaque.StartRegistration(cipherConfiguration.ToNativeConfiguration(), null, request, user.Id.ToString());
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
var registerSession = new RegisterSession() { SessionId = sessionId, ServerSetup = registrationRequest.serverSetup, CipherConfiguration = cipherConfiguration, UserId = user.Id };
|
||||
var registerSession = new OpaqueKeyExchangeRegisterSession() { SessionId = sessionId, ServerSetup = registrationRequest.serverSetup, CipherConfiguration = cipherConfiguration, UserId = user.Id };
|
||||
await _distributedCache.SetAsync(string.Format(REGISTER_SESSION_KEY, sessionId), Encoding.ASCII.GetBytes(JsonSerializer.Serialize(registerSession)));
|
||||
|
||||
return new OpaqueRegistrationStartResponse(sessionId, Convert.ToBase64String(registrationRequest.registrationResponse));
|
||||
@ -57,7 +57,7 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
|
||||
|
||||
try
|
||||
{
|
||||
var registerSession = JsonSerializer.Deserialize<RegisterSession>(Encoding.ASCII.GetString(serializedRegisterSession))!;
|
||||
var registerSession = JsonSerializer.Deserialize<OpaqueKeyExchangeRegisterSession>(Encoding.ASCII.GetString(serializedRegisterSession))!;
|
||||
var registrationFinish = _bitwardenOpaque.FinishRegistration(registerSession.CipherConfiguration.ToNativeConfiguration(), registrationUpload);
|
||||
registerSession.PasswordFile = registrationFinish.serverRegistration;
|
||||
registerSession.KeySet = Encoding.ASCII.GetBytes(JsonSerializer.Serialize(keyset));
|
||||
@ -86,14 +86,18 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
|
||||
throw new InvalidOperationException("Credential not found");
|
||||
}
|
||||
|
||||
var cipherConfiguration = JsonSerializer.Deserialize<Models.Api.Request.Opaque.CipherConfiguration>(credential.CipherConfiguration)!;
|
||||
var cipherConfiguration = JsonSerializer.Deserialize<OpaqueKeyExchangeCipherConfiguration>(credential.CipherConfiguration)!;
|
||||
var credentialBlob = JsonSerializer.Deserialize<OpaqueKeyExchangeCredentialBlob>(credential.CredentialBlob)!;
|
||||
var serverSetup = credentialBlob.ServerSetup;
|
||||
var serverRegistration = credentialBlob.PasswordFile;
|
||||
|
||||
var loginResponse = _bitwardenOpaque.StartLogin(cipherConfiguration.ToNativeConfiguration(), serverSetup, serverRegistration, request, user.Id.ToString());
|
||||
var sessionId = Guid.NewGuid();
|
||||
var loginSession = new LoginSession() { UserId = user.Id, LoginState = loginResponse.state, CipherConfiguration = cipherConfiguration };
|
||||
var loginSession = new OpaqueKeyExchangeLoginSession() {
|
||||
UserId = user.Id,
|
||||
LoginState = loginResponse.state,
|
||||
CipherConfiguration = cipherConfiguration
|
||||
};
|
||||
await _distributedCache.SetAsync(string.Format(LOGIN_SESSION_KEY, sessionId), Encoding.ASCII.GetBytes(JsonSerializer.Serialize(loginSession)));
|
||||
return (sessionId, loginResponse.credentialResponse);
|
||||
}
|
||||
@ -105,7 +109,7 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
|
||||
{
|
||||
throw new InvalidOperationException("Session not found");
|
||||
}
|
||||
var loginSession = JsonSerializer.Deserialize<LoginSession>(Encoding.ASCII.GetString(serializedLoginSession))!;
|
||||
var loginSession = JsonSerializer.Deserialize<OpaqueKeyExchangeLoginSession>(Encoding.ASCII.GetString(serializedLoginSession))!;
|
||||
|
||||
var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(loginSession.UserId);
|
||||
if (credential == null)
|
||||
@ -137,7 +141,7 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
|
||||
{
|
||||
throw new InvalidOperationException("Session not found");
|
||||
}
|
||||
var session = JsonSerializer.Deserialize<RegisterSession>(Encoding.ASCII.GetString(serializedRegisterSession))!;
|
||||
var session = JsonSerializer.Deserialize<OpaqueKeyExchangeRegisterSession>(Encoding.ASCII.GetString(serializedRegisterSession))!;
|
||||
|
||||
if (session.UserId != user.Id)
|
||||
{
|
||||
@ -184,19 +188,19 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
|
||||
}
|
||||
}
|
||||
|
||||
public class RegisterSession
|
||||
public class OpaqueKeyExchangeRegisterSession
|
||||
{
|
||||
public required Guid SessionId { get; set; }
|
||||
public required byte[] ServerSetup { get; set; }
|
||||
public required Models.Api.Request.Opaque.CipherConfiguration CipherConfiguration { get; set; }
|
||||
public required OpaqueKeyExchangeCipherConfiguration CipherConfiguration { get; set; }
|
||||
public required Guid UserId { get; set; }
|
||||
public byte[]? PasswordFile { get; set; }
|
||||
public byte[]? KeySet { get; set; }
|
||||
}
|
||||
|
||||
public class LoginSession
|
||||
public class OpaqueKeyExchangeLoginSession
|
||||
{
|
||||
public required Guid UserId { get; set; }
|
||||
public required byte[] LoginState { get; set; }
|
||||
public required Models.Api.Request.Opaque.CipherConfiguration CipherConfiguration { get; set; }
|
||||
public required OpaqueKeyExchangeCipherConfiguration CipherConfiguration { get; set; }
|
||||
}
|
||||
|
@ -816,7 +816,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
user.ForcePasswordReset = true;
|
||||
user.Key = key;
|
||||
|
||||
// TODO: Add support
|
||||
// TODO: Add Opaque-KE support
|
||||
await _opaqueKeyExchangeService.Unenroll(user);
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
await _mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName());
|
||||
@ -844,7 +844,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
user.Key = key;
|
||||
user.MasterPasswordHint = hint;
|
||||
|
||||
// TODO: Add support
|
||||
// TODO: Add Opaque-KE support
|
||||
await _opaqueKeyExchangeService.Unenroll(user);
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
await _mailService.SendUpdatedTempPasswordEmailAsync(user.Email, user.Name);
|
||||
|
@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Bit.Api.Auth.Models.Request.Opaque;
|
||||
using Bit.Api.Auth.Models.Response.Opaque;
|
||||
using System.Text.Json;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Enums;
|
||||
@ -48,6 +50,7 @@ public class AccountsController : Controller
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
|
||||
private readonly IOpaqueKeyExchangeService _opaqueKeyExchangeService;
|
||||
private readonly IOpaqueKeyExchangeCredentialRepository _opaqueKeyExchangeCredentialRepository;
|
||||
|
||||
private readonly byte[] _defaultKdfHmacKey = null;
|
||||
@ -98,6 +101,7 @@ public class AccountsController : Controller
|
||||
IFeatureService featureService,
|
||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
|
||||
IOpaqueKeyExchangeCredentialRepository opaqueKeyExchangeCredentialRepository,
|
||||
IOpaqueKeyExchangeService opaqueKeyExchangeService,
|
||||
GlobalSettings globalSettings
|
||||
)
|
||||
{
|
||||
@ -112,6 +116,7 @@ public class AccountsController : Controller
|
||||
_referenceEventService = referenceEventService;
|
||||
_featureService = featureService;
|
||||
_registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory;
|
||||
_opaqueKeyExchangeService = opaqueKeyExchangeService;
|
||||
_opaqueKeyExchangeCredentialRepository = opaqueKeyExchangeCredentialRepository;
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.KdfDefaultHashKey))
|
||||
@ -204,33 +209,27 @@ public class AccountsController : Controller
|
||||
model.EmailVerificationToken);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.OrganizationInvite:
|
||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
|
||||
model.OrgInviteToken, model.OrganizationUserId);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan:
|
||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.EmergencyAccessInvite:
|
||||
Debug.Assert(model.AcceptEmergencyAccessId.HasValue);
|
||||
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
|
||||
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.ProviderInvite:
|
||||
Debug.Assert(model.ProviderUserId.HasValue);
|
||||
identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash,
|
||||
model.ProviderInviteToken, model.ProviderUserId.Value);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BadRequestException("Invalid registration finish request");
|
||||
}
|
||||
@ -270,7 +269,7 @@ public class AccountsController : Controller
|
||||
var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(user.Id);
|
||||
if (credential != null)
|
||||
{
|
||||
return new PreloginResponseModel(kdfInformation, JsonSerializer.Deserialize<CipherConfiguration>(credential.CipherConfiguration)!);
|
||||
return new PreloginResponseModel(kdfInformation, JsonSerializer.Deserialize<OpaqueKeyExchangeCipherConfiguration>(credential.CipherConfiguration)!);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -293,6 +292,14 @@ public class AccountsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("opaque-ke/start-login")]
|
||||
[RequireFeature(FeatureFlagKeys.OpaqueKeyExchange)]
|
||||
public async Task<OpaqueLoginStartResponse> GetOpaqueKeyExchangeStartLoginMaterial([FromBody] OpaqueLoginStartRequest request)
|
||||
{
|
||||
var result = await _opaqueKeyExchangeService.StartLogin(Convert.FromBase64String(request.CredentialRequest), request.Email);
|
||||
return new OpaqueLoginStartResponse(result.Item1, Convert.ToBase64String(result.Item2));
|
||||
}
|
||||
|
||||
private UserKdfInformation GetDefaultKdf(string email)
|
||||
{
|
||||
if (_defaultKdfHmacKey == null)
|
||||
|
@ -6,7 +6,7 @@ namespace Bit.Identity.Models.Response.Accounts;
|
||||
|
||||
public class PreloginResponseModel
|
||||
{
|
||||
public PreloginResponseModel(UserKdfInformation kdfInformation, CipherConfiguration opaqueConfiguration)
|
||||
public PreloginResponseModel(UserKdfInformation kdfInformation, OpaqueKeyExchangeCipherConfiguration opaqueConfiguration)
|
||||
{
|
||||
Kdf = kdfInformation.Kdf;
|
||||
KdfIterations = kdfInformation.KdfIterations;
|
||||
@ -19,5 +19,5 @@ public class PreloginResponseModel
|
||||
public int KdfIterations { get; set; }
|
||||
public int? KdfMemory { get; set; }
|
||||
public int? KdfParallelism { get; set; }
|
||||
public CipherConfiguration OpaqueConfiguration { get; set; }
|
||||
public OpaqueKeyExchangeCipherConfiguration OpaqueConfiguration { get; set; }
|
||||
}
|
||||
|
@ -25,8 +25,7 @@ public class OpaqueKeyExchangeCredentialRepository : Repository<OpaqueKeyExchang
|
||||
|
||||
public async Task<OpaqueKeyExchangeCredential?> GetByUserIdAsync(Guid userId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var results = await connection.QueryAsync<OpaqueKeyExchangeCredential>(
|
||||
$"[{Schema}].[{Table}_ReadByUserId]",
|
||||
new { UserId = userId },
|
||||
@ -34,7 +33,6 @@ public class OpaqueKeyExchangeCredentialRepository : Repository<OpaqueKeyExchang
|
||||
|
||||
return results.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - How do we want to handle rotation?
|
||||
public UpdateEncryptedDataForKeyRotation UpdateKeysForRotationAsync(Guid userId, IEnumerable<OpaqueKeyExchangeRotateKeyData> credentials)
|
||||
|
@ -8,7 +8,7 @@ CREATE TABLE [dbo].[OpaqueKeyExchangeCredential]
|
||||
[EncryptedPrivateKey] VARCHAR(MAX) NOT NULL,
|
||||
[EncryptedUserKey] VARCHAR(MAX) NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_OpaqueKeyExchangeCredential] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [PK_OpaqueKeyExchangeCredential] PRIMARY KEY CLUSTERED ([UserId]), -- using this as the primary key ensure users only have one credential
|
||||
CONSTRAINT [FK_OpaqueKeyExchangeCredential_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id])
|
||||
);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user