1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 05:00:19 -05:00

Innovation/opaque grant validator (#5533)

* Add grant validator

* Fix 2fa

* Add featureflag

* Add comments

* Cleanup

* Set active endpoint

* Fix test
This commit is contained in:
Bernd Schoolmann 2025-03-20 15:13:05 +01:00 committed by GitHub
parent 9848d53683
commit 5a8bf4c890
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 231 additions and 14 deletions

View File

@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers;
[Route("opaque")]
[Authorize("Web")]
public class OpaqueKeyExchangeController : Controller
{
private readonly IOpaqueKeyExchangeService _opaqueKeyExchangeService;
@ -25,6 +24,7 @@ public class OpaqueKeyExchangeController : Controller
_userService = userService;
}
[Authorize("Web")]
[HttpPost("~/opaque/start-registration")]
public async Task<OpaqueRegistrationStartResponse> StartRegistrationAsync([FromBody] OpaqueRegistrationStartRequest request)
{
@ -34,6 +34,7 @@ public class OpaqueKeyExchangeController : Controller
}
[Authorize("Web")]
[HttpPost("~/opaque/finish-registration")]
public async void FinishRegistrationAsync([FromBody] OpaqueRegistrationFinishRequest request)
{
@ -41,6 +42,13 @@ public class OpaqueKeyExchangeController : Controller
await _opaqueKeyExchangeService.FinishRegistration(request.SessionId, Convert.FromBase64String(request.RegistrationUpload), user, request.KeySet);
}
[Authorize("Web")]
[HttpPost("~/opaque/set-registration-active")]
public async void SetRegistrationActive([FromBody] OpaqueSetRegistrationActiveRequest request)
{
var user = await _userService.GetUserByPrincipalAsync(User);
await _opaqueKeyExchangeService.SetRegistrationActiveForAccount(request.SessionId, user);
}
// TODO: Remove and move to token endpoint
[HttpPost("~/opaque/start-login")]

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Auth.Models.Api.Request.Opaque;
public class OpaqueSetRegistrationActiveRequest
{
[Required]
public Guid SessionId { get; set; }
}

View File

@ -35,22 +35,34 @@ public interface IOpaqueKeyExchangeService
/// <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.
/// Accepts the client's login request and validates it against the server's crypto material. If successful then the session is marked as authenticated.
/// 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.
/// the session is not marked as authenticated.
/// </summary>
/// <param name="sessionId"></param>
/// <param name="finishCredential"></param>
/// <returns></returns>
public Task<bool> FinishLogin(Guid sessionId, byte[] finishCredential);
/// <summary>
/// Returns the user for the authentication session, or null if the session is invalid or has not yet finished authentication.
/// </summary>
/// <param name="sessionId"></param>
/// <returns></returns>
public Task<User> GetUserForAuthenticatedSession(Guid sessionId);
/// <summary>
/// Clears the authentication session from the cache.
/// </summary>
/// <param name="sessionId"></param>
/// <returns></returns>
public Task ClearAuthenticationSession(Guid sessionId);
/// <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);
public Task SetRegistrationActiveForAccount(Guid sessionId, User user);
/// <summary>
/// Removes the credential for the user.
/// </summary>

View File

@ -1,4 +1,5 @@
using System.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Request.Opaque;
@ -92,11 +93,13 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
var serverRegistration = credentialBlob.PasswordFile;
var loginResponse = _bitwardenOpaque.StartLogin(cipherConfiguration.ToNativeConfiguration(), serverSetup, serverRegistration, request, user.Id.ToString());
var sessionId = Guid.NewGuid();
var loginSession = new OpaqueKeyExchangeLoginSession() {
var sessionId = MakeCryptoGuid();
var loginSession = new OpaqueKeyExchangeLoginSession()
{
UserId = user.Id,
LoginState = loginResponse.state,
CipherConfiguration = cipherConfiguration
CipherConfiguration = cipherConfiguration,
IsAuthenticated = false
};
await _distributedCache.SetAsync(string.Format(LOGIN_SESSION_KEY, sessionId), Encoding.ASCII.GetBytes(JsonSerializer.Serialize(loginSession)));
return (sessionId, loginResponse.credentialResponse);
@ -124,6 +127,8 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
try
{
var success = _bitwardenOpaque.FinishLogin(cipherConfiguration.ToNativeConfiguration(), loginState, credentialFinalization);
loginSession.IsAuthenticated = true;
await _distributedCache.SetAsync(string.Format(LOGIN_SESSION_KEY, sessionId), Encoding.ASCII.GetBytes(JsonSerializer.Serialize(loginSession)));
return true;
}
catch (Exception e)
@ -134,7 +139,24 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
}
}
public async Task SetActive(Guid sessionId, User user)
public async Task<User?> GetUserForAuthenticatedSession(Guid sessionId)
{
var serializedLoginSession = await _distributedCache.GetAsync(string.Format(LOGIN_SESSION_KEY, sessionId));
if (serializedLoginSession == null)
{
throw new InvalidOperationException("Session not found");
}
var loginSession = JsonSerializer.Deserialize<OpaqueKeyExchangeLoginSession>(Encoding.ASCII.GetString(serializedLoginSession))!;
if (!loginSession.IsAuthenticated)
{
throw new InvalidOperationException("Session not authenticated");
}
return await _userRepository.GetByIdAsync(loginSession.UserId!)!;
}
public async Task SetRegistrationActiveForAccount(Guid sessionId, User user)
{
var serializedRegisterSession = await _distributedCache.GetAsync(string.Format(REGISTER_SESSION_KEY, sessionId));
if (serializedRegisterSession == null)
@ -186,6 +208,23 @@ public class OpaqueKeyExchangeService : IOpaqueKeyExchangeService
await _opaqueKeyExchangeCredentialRepository.DeleteAsync(credential);
}
}
private static Guid MakeCryptoGuid()
{
// Get 16 cryptographically random bytes
byte[] data = RandomNumberGenerator.GetBytes(16);
// Mark it as a version 4 GUID
data[7] = (byte)((data[7] | (byte)0x40) & (byte)0x4f);
data[8] = (byte)((data[8] | (byte)0x80) & (byte)0xbf);
return new Guid(data);
}
public async Task ClearAuthenticationSession(Guid sessionId)
{
await _distributedCache.RemoveAsync(string.Format(LOGIN_SESSION_KEY, sessionId));
}
}
public class OpaqueKeyExchangeRegisterSession
@ -203,4 +242,5 @@ public class OpaqueKeyExchangeLoginSession
public required Guid UserId { get; set; }
public required byte[] LoginState { get; set; }
public required OpaqueKeyExchangeCipherConfiguration CipherConfiguration { get; set; }
public required bool IsAuthenticated { get; set; }
}

View File

@ -670,7 +670,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
if (opaqueSessionId != null)
{
await _opaqueKeyExchangeService.SetActive((Guid)opaqueSessionId, user);
await _opaqueKeyExchangeService.SetRegistrationActiveForAccount((Guid)opaqueSessionId, user);
}
else
{

View File

@ -1,8 +1,8 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
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;
using Bit.Core.Auth.Models.Api.Request.Accounts;
@ -267,7 +267,7 @@ public class AccountsController : Controller
}
var credential = await _opaqueKeyExchangeCredentialRepository.GetByUserIdAsync(user.Id);
if (credential != null)
if (credential != null && _featureService.IsEnabled(FeatureFlagKeys.OpaqueKeyExchange))
{
return new PreloginResponseModel(kdfInformation, JsonSerializer.Deserialize<OpaqueKeyExchangeCipherConfiguration>(credential.CipherConfiguration)!);
}

View File

@ -14,7 +14,7 @@ public class ApiClient : Client
string[] scopes = null)
{
ClientId = id;
AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType };
AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType, OpaqueKeyExchangeGrantValidator.GrantType };
RefreshTokenExpiration = TokenExpiration.Sliding;
RefreshTokenUsage = TokenUsage.ReUse;
SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays;

View File

@ -0,0 +1,144 @@
using System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer.RequestValidators;
public class OpaqueKeyExchangeGrantValidator : BaseRequestValidator<ExtensionGrantValidationContext>, IExtensionGrantValidator
{
public const string GrantType = "opaque-ke";
private IUserRepository userRepository;
private IOpaqueKeyExchangeService opaqueKeyExchangeService;
public OpaqueKeyExchangeGrantValidator(
UserManager<User> userManager,
IUserService userService,
IEventService eventService,
IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
IOrganizationUserRepository organizationUserRepository,
IMailService mailService,
ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext,
GlobalSettings globalSettings,
ISsoConfigRepository ssoConfigRepository,
IUserRepository userRepository,
IPolicyService policyService,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IOpaqueKeyExchangeService opaqueKeyExchangeService)
: base(
userManager,
userService,
eventService,
deviceValidator,
twoFactorAuthenticationValidator,
organizationUserRepository,
mailService,
logger,
currentContext,
globalSettings,
userRepository,
policyService,
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder)
{
this.userRepository = userRepository;
this.opaqueKeyExchangeService = opaqueKeyExchangeService;
}
string IExtensionGrantValidator.GrantType => "opaque-ke";
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var sessionId = context.Request.Raw.Get("sessionId");
if (string.IsNullOrWhiteSpace(sessionId))
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var user = await opaqueKeyExchangeService.GetUserForAuthenticatedSession(Guid.Parse(sessionId));
if (user == null)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
await ValidateAsync(context, context.Request, new CustomValidatorRequestContext { User = user });
}
protected override Task<bool> ValidateContextAsync(ExtensionGrantValidationContext context,
CustomValidatorRequestContext validatorContext)
{
if (validatorContext.User == null)
{
return Task.FromResult(false);
}
return Task.FromResult(true);
}
protected override async Task SetSuccessResult(ExtensionGrantValidationContext context, User user,
List<Claim> claims, Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(user.Id.ToString(), "Application",
identityProvider: Constants.IdentityProvider,
claims: claims.Count > 0 ? claims : null,
customResponse: customResponse);
await opaqueKeyExchangeService.ClearAuthenticationSession(Guid.Parse(context.Request.Raw.Get("sessionId")));
}
protected override ClaimsPrincipal GetSubject(ExtensionGrantValidationContext context)
{
return context.Result.Subject;
}
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
customResponse);
}
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected override void SetSsoResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.",
customResponse);
}
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected override void SetErrorResult(ExtensionGrantValidationContext context, Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
}
protected override void SetValidationErrorResult(
ExtensionGrantValidationContext context, CustomValidatorRequestContext requestContext)
{
context.Result = new GrantValidationResult
{
Error = requestContext.ValidationErrorResult.Error,
ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription,
IsError = true,
CustomResponse = requestContext.CustomResponse
};
}
}

View File

@ -53,7 +53,8 @@ public static class ServiceCollectionExtensions
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
.AddClientStore<ClientStore>()
.AddIdentityServerCertificate(env, globalSettings)
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
.AddExtensionGrantValidator<WebAuthnGrantValidator>()
.AddExtensionGrantValidator<OpaqueKeyExchangeGrantValidator>();
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
{

View File

@ -47,6 +47,7 @@ public class AccountsControllerTests : IDisposable
private readonly IFeatureService _featureService;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
private readonly IOpaqueKeyExchangeCredentialRepository _opaqueKeyExchangeCredentialRepository;
private readonly IOpaqueKeyExchangeService _opaqueKeyExchangeService;
private readonly GlobalSettings _globalSettings;
@ -64,6 +65,7 @@ public class AccountsControllerTests : IDisposable
_featureService = Substitute.For<IFeatureService>();
_registrationEmailVerificationTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>();
_opaqueKeyExchangeCredentialRepository = Substitute.For<IOpaqueKeyExchangeCredentialRepository>();
_opaqueKeyExchangeService = Substitute.For<IOpaqueKeyExchangeService>();
_globalSettings = Substitute.For<GlobalSettings>();
_sut = new AccountsController(
@ -79,6 +81,7 @@ public class AccountsControllerTests : IDisposable
_featureService,
_registrationEmailVerificationTokenDataFactory,
_opaqueKeyExchangeCredentialRepository,
_opaqueKeyExchangeService,
_globalSettings
);
}