mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12:49 -05:00
[PM-20348] Add pending auth request endpoint (#5957)
* Feat(pm-20348): * Add migration scripts for Read Pending Auth Requests by UserId stored procedure and new `view` for pending AuthRequest. * View only returns the most recent pending authRequest, or none at all if the most recent is answered. * Implement stored procedure in AuthRequestRepository for both Dapper and Entity Framework. * Update AuthRequestController to query the new View to get a user's most recent pending auth requests response includes the requesting deviceId. * Doc: * Move summary xml comments to interface. * Added comments for the AuthRequestService. * Test: * Added testing for AuthRequestsController. * Added testing for repositories. * Added integration tests for multiple auth requests but only returning the most recent.
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
using Bit.Api.Auth.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Auth.Services;
|
||||
@ -7,6 +8,7 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -14,31 +16,23 @@ namespace Bit.Api.Auth.Controllers;
|
||||
|
||||
[Route("auth-requests")]
|
||||
[Authorize("Application")]
|
||||
public class AuthRequestsController : Controller
|
||||
public class AuthRequestsController(
|
||||
IUserService userService,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IAuthRequestService authRequestService) : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IAuthRequestService _authRequestService;
|
||||
|
||||
public AuthRequestsController(
|
||||
IUserService userService,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IAuthRequestService authRequestService)
|
||||
{
|
||||
_userService = userService;
|
||||
_authRequestRepository = authRequestRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_authRequestService = authRequestService;
|
||||
}
|
||||
private readonly IUserService _userService = userService;
|
||||
private readonly IAuthRequestRepository _authRequestRepository = authRequestRepository;
|
||||
private readonly IGlobalSettings _globalSettings = globalSettings;
|
||||
private readonly IAuthRequestService _authRequestService = authRequestService;
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<AuthRequestResponseModel>> Get()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId);
|
||||
var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)).ToList();
|
||||
var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault));
|
||||
return new ListResponseModel<AuthRequestResponseModel>(responses);
|
||||
}
|
||||
|
||||
@ -56,6 +50,16 @@ public class AuthRequestsController : Controller
|
||||
return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault);
|
||||
}
|
||||
|
||||
[HttpGet("pending")]
|
||||
[RequireFeature(FeatureFlagKeys.BrowserExtensionLoginApproval)]
|
||||
public async Task<ListResponseModel<PendingAuthRequestResponseModel>> GetPendingAuthRequestsAsync()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var rawResponse = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId);
|
||||
var responses = rawResponse.Select(a => new PendingAuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault));
|
||||
return new ListResponseModel<PendingAuthRequestResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/response")]
|
||||
[AllowAnonymous]
|
||||
public async Task<AuthRequestResponseModel> GetResponse(Guid id, [FromQuery] string code)
|
||||
|
@ -0,0 +1,15 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Response;
|
||||
|
||||
public class PendingAuthRequestResponseModel : AuthRequestResponseModel
|
||||
{
|
||||
public PendingAuthRequestResponseModel(PendingAuthRequestDetails authRequest, string vaultUri, string obj = "auth-request")
|
||||
: base(authRequest, vaultUri, obj)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authRequest);
|
||||
RequestDeviceId = authRequest.RequestDeviceId;
|
||||
}
|
||||
|
||||
public Guid? RequestDeviceId { get; set; }
|
||||
}
|
83
src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs
Normal file
83
src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs
Normal file
@ -0,0 +1,83 @@
|
||||
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Data;
|
||||
|
||||
public class PendingAuthRequestDetails : AuthRequest
|
||||
{
|
||||
public Guid? RequestDeviceId { get; set; }
|
||||
|
||||
/**
|
||||
* Constructor for EF response.
|
||||
*/
|
||||
public PendingAuthRequestDetails(
|
||||
AuthRequest authRequest,
|
||||
Guid? deviceId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authRequest);
|
||||
|
||||
Id = authRequest.Id;
|
||||
UserId = authRequest.UserId;
|
||||
OrganizationId = authRequest.OrganizationId;
|
||||
Type = authRequest.Type;
|
||||
RequestDeviceIdentifier = authRequest.RequestDeviceIdentifier;
|
||||
RequestDeviceType = authRequest.RequestDeviceType;
|
||||
RequestIpAddress = authRequest.RequestIpAddress;
|
||||
RequestCountryName = authRequest.RequestCountryName;
|
||||
ResponseDeviceId = authRequest.ResponseDeviceId;
|
||||
AccessCode = authRequest.AccessCode;
|
||||
PublicKey = authRequest.PublicKey;
|
||||
Key = authRequest.Key;
|
||||
MasterPasswordHash = authRequest.MasterPasswordHash;
|
||||
Approved = authRequest.Approved;
|
||||
CreationDate = authRequest.CreationDate;
|
||||
ResponseDate = authRequest.ResponseDate;
|
||||
AuthenticationDate = authRequest.AuthenticationDate;
|
||||
RequestDeviceId = deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for dapper response.
|
||||
*/
|
||||
public PendingAuthRequestDetails(
|
||||
Guid id,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
short type,
|
||||
string requestDeviceIdentifier,
|
||||
short requestDeviceType,
|
||||
string requestIpAddress,
|
||||
string requestCountryName,
|
||||
Guid? responseDeviceId,
|
||||
string accessCode,
|
||||
string publicKey,
|
||||
string key,
|
||||
string masterPasswordHash,
|
||||
bool? approved,
|
||||
DateTime creationDate,
|
||||
DateTime? responseDate,
|
||||
DateTime? authenticationDate,
|
||||
Guid deviceId)
|
||||
{
|
||||
Id = id;
|
||||
UserId = userId;
|
||||
OrganizationId = organizationId;
|
||||
Type = (AuthRequestType)type;
|
||||
RequestDeviceIdentifier = requestDeviceIdentifier;
|
||||
RequestDeviceType = (DeviceType)requestDeviceType;
|
||||
RequestIpAddress = requestIpAddress;
|
||||
RequestCountryName = requestCountryName;
|
||||
ResponseDeviceId = responseDeviceId;
|
||||
AccessCode = accessCode;
|
||||
PublicKey = publicKey;
|
||||
Key = key;
|
||||
MasterPasswordHash = masterPasswordHash;
|
||||
Approved = approved;
|
||||
CreationDate = creationDate;
|
||||
ResponseDate = responseDate;
|
||||
AuthenticationDate = authenticationDate;
|
||||
RequestDeviceId = deviceId;
|
||||
}
|
||||
}
|
@ -9,6 +9,13 @@ public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
|
||||
{
|
||||
Task<int> DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration);
|
||||
Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId);
|
||||
/// <summary>
|
||||
/// Gets all active pending auth requests for a user. Each auth request in the collection will be associated with a different
|
||||
/// device. It will be the most current request for the device.
|
||||
/// </summary>
|
||||
/// <param name="userId">UserId of the owner of the AuthRequests</param>
|
||||
/// <returns>a collection Auth request details or empty</returns>
|
||||
Task<IEnumerable<PendingAuthRequestDetails>> GetManyPendingAuthRequestByUserId(Guid userId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
|
||||
Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests);
|
||||
|
@ -1,5 +1,9 @@
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Exceptions;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
#nullable enable
|
||||
|
||||
@ -7,8 +11,41 @@ namespace Bit.Core.Auth.Services;
|
||||
|
||||
public interface IAuthRequestService
|
||||
{
|
||||
Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId);
|
||||
Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code);
|
||||
/// <summary>
|
||||
/// Fetches an authRequest by Id. Returns AuthRequest if AuthRequest.UserId mateches
|
||||
/// userId. Returns null if the user doesn't match or if the AuthRequest is not found.
|
||||
/// </summary>
|
||||
/// <param name="authRequestId">Authrequest Id being fetched</param>
|
||||
/// <param name="userId">user who owns AuthRequest</param>
|
||||
/// <returns>An AuthRequest or null</returns>
|
||||
Task<AuthRequest?> GetAuthRequestAsync(Guid authRequestId, Guid userId);
|
||||
/// <summary>
|
||||
/// Fetches the authrequest from the database with the id provided. Then checks
|
||||
/// the accessCode against the AuthRequest.AccessCode from the database. accessCodes
|
||||
/// must match the found authRequest, and the AuthRequest must not be expired. Expiration
|
||||
/// is configured in <see cref="GlobalSettings"/>
|
||||
/// </summary>
|
||||
/// <param name="authRequestId">AuthRequest being acted on</param>
|
||||
/// <param name="accessCode">Access code of the authrequest, must match saved database value</param>
|
||||
/// <returns>A valid AuthRequest or null</returns>
|
||||
Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode);
|
||||
/// <summary>
|
||||
/// Validates and Creates an <see cref="AuthRequest" /> in the database, as well as pushes it through notifications services
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can only be called inside of an HTTP call because of it's reliance on <see cref="ICurrentContext" />
|
||||
/// </remarks>
|
||||
Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model);
|
||||
/// <summary>
|
||||
/// Updates the AuthRequest per the AuthRequestUpdateRequestModel context. This approves
|
||||
/// or rejects the login request.
|
||||
/// </summary>
|
||||
/// <param name="authRequestId">AuthRequest being acted on.</param>
|
||||
/// <param name="userId">User acting on AuthRequest</param>
|
||||
/// <param name="model">Update context for the AuthRequest</param>
|
||||
/// <returns>retuns an AuthRequest or throws an exception</returns>
|
||||
/// <exception cref="DuplicateAuthRequestException">Thows if the AuthRequest has already been Approved/Rejected</exception>
|
||||
/// <exception cref="NotFoundException">Throws if the AuthRequest as expired or the userId doesn't match</exception>
|
||||
/// <exception cref="BadRequestException">Throws if the device isn't associated with the UserId</exception>
|
||||
Task<AuthRequest> UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model);
|
||||
}
|
||||
|
@ -58,9 +58,9 @@ public class AuthRequestService : IAuthRequestService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId)
|
||||
public async Task<AuthRequest?> GetAuthRequestAsync(Guid authRequestId, Guid userId)
|
||||
{
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(id);
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
|
||||
if (authRequest == null || authRequest.UserId != userId)
|
||||
{
|
||||
return null;
|
||||
@ -69,10 +69,10 @@ public class AuthRequestService : IAuthRequestService
|
||||
return authRequest;
|
||||
}
|
||||
|
||||
public async Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code)
|
||||
public async Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode)
|
||||
{
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(id);
|
||||
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code))
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
|
||||
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@ -85,12 +85,6 @@ public class AuthRequestService : IAuthRequestService
|
||||
return authRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and Creates an <see cref="AuthRequest" /> in the database, as well as pushes it through notifications services
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can only be called inside of an HTTP call because of it's reliance on <see cref="ICurrentContext" />
|
||||
/// </remarks>
|
||||
public async Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model)
|
||||
{
|
||||
if (!_currentContext.DeviceType.HasValue)
|
||||
|
@ -14,13 +14,12 @@ namespace Bit.Infrastructure.Dapper.Auth.Repositories;
|
||||
|
||||
public class AuthRequestRepository : Repository<AuthRequest, Guid>, IAuthRequestRepository
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
public AuthRequestRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public AuthRequestRepository(string connectionString, string readOnlyConnectionString)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
: base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(
|
||||
TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration)
|
||||
@ -52,6 +51,18 @@ public class AuthRequestRepository : Repository<AuthRequest, Guid>, IAuthRequest
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PendingAuthRequestDetails>> GetManyPendingAuthRequestByUserId(Guid userId)
|
||||
{
|
||||
var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes;
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var results = await connection.QueryAsync<PendingAuthRequestDetails>(
|
||||
$"[{Schema}].[AuthRequest_ReadPendingByUserId]",
|
||||
new { UserId = userId, ExpirationMinutes = expirationMinutes },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
|
@ -3,6 +3,7 @@ using AutoMapper.QueryableExtensions;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.EntityFramework.Auth.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -14,9 +15,13 @@ namespace Bit.Infrastructure.EntityFramework.Auth.Repositories;
|
||||
|
||||
public class AuthRequestRepository : Repository<Core.Auth.Entities.AuthRequest, AuthRequest, Guid>, IAuthRequestRepository
|
||||
{
|
||||
public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.AuthRequests)
|
||||
{ }
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper, IGlobalSettings globalSettings)
|
||||
: base(serviceScopeFactory, mapper, context => context.AuthRequests)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(
|
||||
TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration)
|
||||
{
|
||||
@ -57,6 +62,32 @@ public class AuthRequestRepository : Repository<Core.Auth.Entities.AuthRequest,
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PendingAuthRequestDetails>> GetManyPendingAuthRequestByUserId(Guid userId)
|
||||
{
|
||||
var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes;
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var mostRecentAuthRequests = await
|
||||
(from authRequest in dbContext.AuthRequests
|
||||
where authRequest.Type == AuthRequestType.AuthenticateAndUnlock
|
||||
|| authRequest.Type == AuthRequestType.Unlock
|
||||
where authRequest.UserId == userId
|
||||
where authRequest.CreationDate.AddMinutes(expirationMinutes) >= DateTime.UtcNow
|
||||
group authRequest by authRequest.RequestDeviceIdentifier into groupedAuthRequests
|
||||
select
|
||||
(from r in groupedAuthRequests
|
||||
join d in dbContext.Devices on new { r.RequestDeviceIdentifier, r.UserId }
|
||||
equals new { RequestDeviceIdentifier = d.Identifier, d.UserId } into deviceJoin
|
||||
from dj in deviceJoin.DefaultIfEmpty() // This creates a left join allowing null for devices
|
||||
orderby r.CreationDate descending
|
||||
select new PendingAuthRequestDetails(r, dj.Id)).First()
|
||||
).ToListAsync();
|
||||
|
||||
mostRecentAuthRequests.RemoveAll(a => a.Approved != null);
|
||||
|
||||
return mostRecentAuthRequests;
|
||||
}
|
||||
|
||||
public async Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(
|
||||
Guid organizationId,
|
||||
IEnumerable<Guid> ids)
|
||||
|
@ -0,0 +1,12 @@
|
||||
CREATE PROCEDURE [dbo].[AuthRequest_ReadPendingByUserId]
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@ExpirationMinutes INT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT *
|
||||
FROM [dbo].[AuthRequestPendingDetailsView]
|
||||
WHERE [UserId] = @UserId
|
||||
AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE())
|
||||
END
|
38
src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql
Normal file
38
src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql
Normal file
@ -0,0 +1,38 @@
|
||||
CREATE VIEW [dbo].[AuthRequestPendingDetailsView]
|
||||
AS
|
||||
WITH
|
||||
PendingRequests
|
||||
AS
|
||||
(
|
||||
SELECT
|
||||
[AR].*,
|
||||
[D].[Id] AS [DeviceId],
|
||||
ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn]
|
||||
FROM [dbo].[AuthRequest] [AR]
|
||||
LEFT JOIN [dbo].[Device] [D]
|
||||
ON [AR].[RequestDeviceIdentifier] = [D].[Identifier]
|
||||
AND [D].[UserId] = [AR].[UserId]
|
||||
WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock
|
||||
)
|
||||
SELECT
|
||||
[PR].[Id],
|
||||
[PR].[UserId],
|
||||
[PR].[OrganizationId],
|
||||
[PR].[Type],
|
||||
[PR].[RequestDeviceIdentifier],
|
||||
[PR].[RequestDeviceType],
|
||||
[PR].[RequestIpAddress],
|
||||
[PR].[RequestCountryName],
|
||||
[PR].[ResponseDeviceId],
|
||||
[PR].[AccessCode],
|
||||
[PR].[PublicKey],
|
||||
[PR].[Key],
|
||||
[PR].[MasterPasswordHash],
|
||||
[PR].[Approved],
|
||||
[PR].[CreationDate],
|
||||
[PR].[ResponseDate],
|
||||
[PR].[AuthenticationDate],
|
||||
[PR].[DeviceId]
|
||||
FROM [PendingRequests] [PR]
|
||||
WHERE [PR].[rn] = 1
|
||||
AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null
|
@ -25,9 +25,4 @@
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Core.Test\Core.Test.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Auth\" />
|
||||
<Folder Include="Auth\Controllers\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
258
test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs
Normal file
258
test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs
Normal file
@ -0,0 +1,258 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Api.Auth.Controllers;
|
||||
using Bit.Api.Auth.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.Auth.Controllers;
|
||||
|
||||
[ControllerCustomize(typeof(AuthRequestsController))]
|
||||
[SutProviderCustomize]
|
||||
public class AuthRequestsControllerTests
|
||||
{
|
||||
const string _testGlobalSettingsBaseUri = "https://vault.test.dev";
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_ReturnsExpectedResult(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
User user,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
SetBaseServiceUri(sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user.Id);
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetManyByUserIdAsync(user.Id)
|
||||
.Returns([authRequest]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Get();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var expectedCount = 1;
|
||||
Assert.Equal(result.Data.Count(), expectedCount);
|
||||
Assert.IsType<ListResponseModel<AuthRequestResponseModel>>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetById_ThrowsNotFoundException(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
User user,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user.Id);
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestService>()
|
||||
.GetAuthRequestAsync(authRequest.Id, user.Id)
|
||||
.Returns((AuthRequest)null);
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
var exception = await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => sutProvider.Sut.Get(authRequest.Id));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetById_ReturnsAuthRequest(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
User user,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
SetBaseServiceUri(sutProvider);
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user.Id);
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestService>()
|
||||
.GetAuthRequestAsync(authRequest.Id, user.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Get(authRequest.Id);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<AuthRequestResponseModel>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPending_ReturnsExpectedResult(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
User user,
|
||||
PendingAuthRequestDetails authRequest)
|
||||
{
|
||||
// Arrange
|
||||
SetBaseServiceUri(sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user.Id);
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetManyPendingAuthRequestByUserId(user.Id)
|
||||
.Returns([authRequest]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetPendingAuthRequestsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var expectedCount = 1;
|
||||
Assert.Equal(result.Data.Count(), expectedCount);
|
||||
Assert.IsType<ListResponseModel<PendingAuthRequestResponseModel>>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetResponseById_ThrowsNotFoundException(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IAuthRequestService>()
|
||||
.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode)
|
||||
.Returns((AuthRequest)null);
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
var exception = await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetResponseById_ReturnsAuthRequest(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
SetBaseServiceUri(sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestService>()
|
||||
.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode)
|
||||
.Returns(authRequest);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<AuthRequestResponseModel>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Post_AdminApprovalRequest_ThrowsBadRequestException(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
AuthRequestCreateRequestModel authRequest)
|
||||
{
|
||||
// Arrange
|
||||
authRequest.Type = AuthRequestType.AdminApproval;
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.Post(authRequest));
|
||||
|
||||
var expectedMessage = "You must be authenticated to create a request of that type.";
|
||||
Assert.Equal(exception.Message, expectedMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Post_ReturnsAuthRequest(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
AuthRequestCreateRequestModel requestModel,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
SetBaseServiceUri(sutProvider);
|
||||
|
||||
requestModel.Type = AuthRequestType.AuthenticateAndUnlock;
|
||||
sutProvider.GetDependency<IAuthRequestService>()
|
||||
.CreateAuthRequestAsync(requestModel)
|
||||
.Returns(authRequest);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Post(requestModel);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<AuthRequestResponseModel>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostAdminRequest_ReturnsAuthRequest(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
AuthRequestCreateRequestModel requestModel,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
SetBaseServiceUri(sutProvider);
|
||||
|
||||
requestModel.Type = AuthRequestType.AuthenticateAndUnlock;
|
||||
sutProvider.GetDependency<IAuthRequestService>()
|
||||
.CreateAuthRequestAsync(requestModel)
|
||||
.Returns(authRequest);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.PostAdminRequest(requestModel);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<AuthRequestResponseModel>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Put_ReturnsAuthRequest(
|
||||
SutProvider<AuthRequestsController> sutProvider,
|
||||
User user,
|
||||
AuthRequestUpdateRequestModel requestModel,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Arrange
|
||||
SetBaseServiceUri(sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(user.Id);
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestService>()
|
||||
.UpdateAuthRequestAsync(authRequest.Id, user.Id, requestModel)
|
||||
.Returns(authRequest);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut
|
||||
.Put(authRequest.Id, requestModel);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<AuthRequestResponseModel>(result);
|
||||
}
|
||||
|
||||
private void SetBaseServiceUri(SutProvider<AuthRequestsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.BaseServiceUri
|
||||
.Vault
|
||||
.Returns(_testGlobalSettingsBaseUri);
|
||||
}
|
||||
}
|
@ -66,10 +66,8 @@ public class AuthRequestRepositoryTests
|
||||
Assert.NotNull(await authRequestRepository.GetByIdAsync(notExpiredAdminApprovalRequest.Id));
|
||||
Assert.NotNull(await authRequestRepository.GetByIdAsync(notExpiredApprovedAdminApprovalRequest.Id));
|
||||
|
||||
// Ensure the repository responds with the amount of items it deleted and it deleted the right amount.
|
||||
// NOTE: On local development this might fail on it's first run because the developer could have expired AuthRequests
|
||||
// on their machine but aren't running the job that would delete them. The second run of this test should succeed.
|
||||
Assert.Equal(4, numberOfDeleted);
|
||||
// Ensure the repository responds with the amount of items it deleted and it deleted the right amount, which could include other auth requests from other tests so we take the minimum known acceptable amount.
|
||||
Assert.True(numberOfDeleted >= 4);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
@ -182,7 +180,157 @@ public class AuthRequestRepositoryTests
|
||||
Assert.Null(uncreatedAuthRequest);
|
||||
}
|
||||
|
||||
private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null)
|
||||
/// <summary>
|
||||
/// Test to determine that when no valid authRequest exists in the database the return value is null.
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetManyPendingAuthRequestByUserId_AuthRequestsInvalid_ReturnsEmptyEnumerable_Success(
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
List<AuthRequest> authRequests = [];
|
||||
|
||||
// A user auth request type that has passed its expiration time, should not be returned.
|
||||
var authRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
CreateExpiredDate(_userRequestExpiration));
|
||||
authRequest.RequestDeviceIdentifier = "auth_request_expired";
|
||||
authRequests.Add(await authRequestRepository.CreateAsync(authRequest));
|
||||
|
||||
// A valid time AuthRequest but for pending we do not fetch admin auth requests
|
||||
authRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AdminApproval,
|
||||
DateTime.UtcNow.AddMinutes(-1));
|
||||
authRequest.RequestDeviceIdentifier = "admin_auth_request";
|
||||
authRequests.Add(await authRequestRepository.CreateAsync(authRequest));
|
||||
|
||||
// A valid time AuthRequest but the request has been approved/rejected, so it should not be returned.
|
||||
authRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-1),
|
||||
false);
|
||||
authRequest.RequestDeviceIdentifier = "approved_auth_request";
|
||||
authRequests.Add(await authRequestRepository.CreateAsync(authRequest));
|
||||
|
||||
var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id);
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
|
||||
// Verify that there are authRequests associated with the user.
|
||||
Assert.NotEmpty(await authRequestRepository.GetManyByUserIdAsync(user.Id));
|
||||
|
||||
await CleanupTestAsync(authRequests, authRequestRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test to determine that when multiple valid authRequest exist for a device only the soonest one is returned.
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetManyPendingAuthRequestByUserId_MultipleRequestForSingleDevice_ReturnsMostRecent(
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var oneMinuteOldAuthRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-1));
|
||||
oneMinuteOldAuthRequest = await authRequestRepository.CreateAsync(oneMinuteOldAuthRequest);
|
||||
|
||||
var fiveMinuteOldAuthRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-5));
|
||||
fiveMinuteOldAuthRequest = await authRequestRepository.CreateAsync(fiveMinuteOldAuthRequest);
|
||||
|
||||
var tenMinuteOldAuthRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-10));
|
||||
tenMinuteOldAuthRequest = await authRequestRepository.CreateAsync(tenMinuteOldAuthRequest);
|
||||
|
||||
var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id);
|
||||
Assert.NotNull(result);
|
||||
// since we group by device there should only be a single return since the device Id is the same
|
||||
Assert.Single(result);
|
||||
var resultAuthRequest = result.First();
|
||||
Assert.Equal(oneMinuteOldAuthRequest.Id, resultAuthRequest.Id);
|
||||
|
||||
List<AuthRequest> authRequests = [oneMinuteOldAuthRequest, fiveMinuteOldAuthRequest, tenMinuteOldAuthRequest];
|
||||
|
||||
await CleanupTestAsync(authRequests, authRequestRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test to determine that when multiple authRequests exist for a device if the most recent is approved then
|
||||
/// there should be no return.
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetManyPendingAuthRequestByUserId_MultipleRequestForSingleDevice_MostRecentIsApproved_ReturnsEmpty(
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
// approved auth request
|
||||
var oneMinuteOldAuthRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-1),
|
||||
false);
|
||||
oneMinuteOldAuthRequest = await authRequestRepository.CreateAsync(oneMinuteOldAuthRequest);
|
||||
|
||||
var fiveMinuteOldAuthRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-5));
|
||||
fiveMinuteOldAuthRequest = await authRequestRepository.CreateAsync(fiveMinuteOldAuthRequest);
|
||||
|
||||
var tenMinuteOldAuthRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-10));
|
||||
tenMinuteOldAuthRequest = await authRequestRepository.CreateAsync(tenMinuteOldAuthRequest);
|
||||
|
||||
var result = await authRequestRepository.GetManyPendingAuthRequestByUserId(user.Id);
|
||||
Assert.NotNull(result);
|
||||
// result should be empty since the most recent request was addressed
|
||||
Assert.Empty(result);
|
||||
|
||||
List<AuthRequest> authRequests = [oneMinuteOldAuthRequest, fiveMinuteOldAuthRequest, tenMinuteOldAuthRequest];
|
||||
await CleanupTestAsync(authRequests, authRequestRepository);
|
||||
}
|
||||
|
||||
private static AuthRequest CreateAuthRequest(
|
||||
Guid userId,
|
||||
AuthRequestType authRequestType,
|
||||
DateTime creationDate,
|
||||
bool? approved = null,
|
||||
DateTime? responseDate = null)
|
||||
{
|
||||
return new AuthRequest
|
||||
{
|
||||
@ -203,4 +351,20 @@ public class AuthRequestRepositoryTests
|
||||
var exp = expirationPeriod + TimeSpan.FromMinutes(1);
|
||||
return DateTime.UtcNow.Add(exp.Negate());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the test data created by the test methods. This supports the DeleteExpiredAsync Test.
|
||||
/// </summary>
|
||||
/// <param name="authRequests">Created Auth Requests</param>
|
||||
/// <param name="authRequestRepository">repository context for the current test</param>
|
||||
/// <returns>void</returns>
|
||||
private static async Task CleanupTestAsync(
|
||||
IEnumerable<AuthRequest> authRequests,
|
||||
IAuthRequestRepository authRequestRepository)
|
||||
{
|
||||
foreach (var authRequest in authRequests)
|
||||
{
|
||||
await authRequestRepository.DeleteAsync(authRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,53 @@
|
||||
CREATE OR ALTER VIEW [dbo].[AuthRequestPendingDetailsView]
|
||||
AS
|
||||
WITH
|
||||
PendingRequests
|
||||
AS
|
||||
(
|
||||
SELECT
|
||||
[AR].*,
|
||||
[D].[Id] AS [DeviceId],
|
||||
ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn]
|
||||
FROM [dbo].[AuthRequest] [AR]
|
||||
LEFT JOIN [dbo].[Device] [D]
|
||||
ON [AR].[RequestDeviceIdentifier] = [D].[Identifier]
|
||||
AND [D].[UserId] = [AR].[UserId]
|
||||
WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock
|
||||
)
|
||||
SELECT
|
||||
[PR].[Id],
|
||||
[PR].[UserId],
|
||||
[PR].[OrganizationId],
|
||||
[PR].[Type],
|
||||
[PR].[RequestDeviceIdentifier],
|
||||
[PR].[RequestDeviceType],
|
||||
[PR].[RequestIpAddress],
|
||||
[PR].[RequestCountryName],
|
||||
[PR].[ResponseDeviceId],
|
||||
[PR].[AccessCode],
|
||||
[PR].[PublicKey],
|
||||
[PR].[Key],
|
||||
[PR].[MasterPasswordHash],
|
||||
[PR].[Approved],
|
||||
[PR].[CreationDate],
|
||||
[PR].[ResponseDate],
|
||||
[PR].[AuthenticationDate],
|
||||
[PR].[DeviceId]
|
||||
FROM [PendingRequests] [PR]
|
||||
WHERE [PR].[rn] = 1
|
||||
AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE [dbo].[AuthRequest_ReadPendingByUserId]
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@ExpirationMinutes INT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT *
|
||||
FROM [dbo].[AuthRequestPendingDetailsView]
|
||||
WHERE [UserId] = @UserId
|
||||
AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE())
|
||||
END
|
||||
GO
|
Reference in New Issue
Block a user