1
0
mirror of https://github.com/bitwarden/server synced 2026-01-03 17:14:00 +00: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:
Ike
2025-06-30 13:17:51 -04:00
committed by GitHub
parent 899ff1b660
commit 20bf1455cf
14 changed files with 752 additions and 50 deletions

View File

@@ -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)

View File

@@ -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; }
}

View 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;
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View 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