diff --git a/src/Core/Auth/Features/PasswordValidation/PasswordValidationConstants.cs b/src/Core/Auth/Features/PasswordValidation/PasswordValidationConstants.cs new file mode 100644 index 0000000000..f8daaeab38 --- /dev/null +++ b/src/Core/Auth/Features/PasswordValidation/PasswordValidationConstants.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Auth.PasswordValidation; + +public static class PasswordValidationConstants +{ + public const int PasswordHasherKdfIterations = 100000; +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccessGrantValidator.cs index f2d7d5af08..4d9629c940 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccessGrantValidator.cs @@ -1,19 +1,24 @@ using System.Security.Claims; +using Bit.Core.Entities; using Bit.Core.Identity; using Bit.Core.Tools.Repositories; using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; +using Microsoft.AspNetCore.Identity; namespace Bit.Identity.IdentityServer.RequestValidators; -// TODO: in real implementation, we would use ideally use Tools provided Send queries for the data we need -// instead of directly injecting the send repository here. -public class SendAccessGrantValidator(ISendRepository sendRepository) : IExtensionGrantValidator +public class SendAccessGrantValidator(ISendRepository sendRepository, IPasswordHasher passwordHasher) : IExtensionGrantValidator { public const string GrantType = "send_access"; string IExtensionGrantValidator.GrantType => GrantType; + private const string _invalidRequestMissingSendIdMessage = "Invalid request. send_id is required."; + private const string _invalidRequestPasswordRequiredMessage = "Invalid request. Password is required."; + private const string _invalidGrantPasswordInvalid = "Password invalid."; + // TODO: add email OTP validation error messages here. + public async Task ValidateAsync(ExtensionGrantValidationContext context) { var request = context.Request.Raw; @@ -22,40 +27,59 @@ public class SendAccessGrantValidator(ISendRepository sendRepository) : IExtensi if (string.IsNullOrEmpty(sendId)) { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "Invalid request. send_id is required."); + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, errorDescription: _invalidRequestMissingSendIdMessage); return; } if (!Guid.TryParse(sendId, out var sendIdGuid)) { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "Invalid request. send_id is required."); + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, errorDescription: _invalidRequestMissingSendIdMessage); return; } + // TODO: replace repository look up & following logic with use of SendAuthenticationQuery.GetAuthenticationMethod from Tools + // See below for example of consumption of SendAuthQuery.GetAuthenticationMethod(sendId) + // Look up send by id var send = await sendRepository.GetByIdAsync(sendIdGuid); - // SendAuthQuery from Tools will return authN type + if a send doesn't exist, then we will add enumeration protection - // Only will map to password or email + OTP protected. If user submits password guess for a falsely protected send, then - // return invalid password. - if (send == null) { - // TODO: evaluate if adding send enumeration protection is required here as rate limiting is present already on the token endpoint - // Yes, it does help with self hosted instances. We will + // TODO: Add send enumeration protection here (primarily benefits self hosted instances). + // We should only map to password or email + OTP protected. If user submits password guess for a + // falsely protected send, then we will return invalid password. + // TODO: we should re-use _invalidGrantPasswordInvalid or similar error message here. context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "Invalid request"); return; } - - // if send is anon, provide access token - if (string.IsNullOrEmpty(send.Password)) + if (!string.IsNullOrEmpty(send.Password)) { - // context.Result = new GrantValidationResult(subject: sendId); + // Send is password protected so we need to validate the password. + var password = request.Get("password"); + if (string.IsNullOrEmpty(password)) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, errorDescription: _invalidRequestPasswordRequiredMessage); + return; + } + + var passwordValid = ValidateSendPassword(send.Password, password); + + if (!passwordValid) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, errorDescription: _invalidGrantPasswordInvalid); + return; + } + + // password is valid, so we can issue an access token. context.Result = BuildBaseSuccessResult(sendId); return; } + // if send is anon, provide access token + + context.Result = BuildBaseSuccessResult(sendId); + // Email + OTP - if we generate OTP here, we could run into rate limiting issues with re-hitting this endpoint // We will generate & validate OTP here. @@ -64,8 +88,37 @@ public class SendAccessGrantValidator(ISendRepository sendRepository) : IExtensi // if send is password protected, check if password is provided and validate if so // if send is email + OTP protected, check if email and OTP are provided and validate if so + // TODO: Example Consumption of SendAuthQuery.GetAuthenticationMethod(sendId) to replace above logic. + + // var method = await sendAuthQuery.GetAuthenticationMethod(sendId); + // + // switch (method) + // { + // case NeverAuthenticate: + // // null send scenario. + // HandleNullSend(); // this is where we add send enumeration protection + // break; + // + // case NotAuthenticated: + // // automatically issue access token + // break; + // + // case ResourcePassword rp: + // ValidatePassword(rp.Hash); + // break; + // + // case EmailOtp eo: + // We will either send the OTP here or validate it. + // SendOtpToEmails(eo.Emails) or ValidateOtp(eo.Emails); + // break; + // + // default: + // // shouldn’t ever hit this + // throw new InvalidOperationException($"Unknown auth method: {method.GetType()}"); + // } + + - context.Result = new GrantValidationResult("send_access", GrantType); } private GrantValidationResult BuildBaseSuccessResult(string sendId) @@ -82,4 +135,15 @@ public class SendAccessGrantValidator(ISendRepository sendRepository) : IExtensi authenticationMethod: GrantType, claims: claims); } + + private bool ValidateSendPassword(string sendPassword, string userSubmittedPassword) + { + if (string.IsNullOrWhiteSpace(sendPassword) || string.IsNullOrWhiteSpace(userSubmittedPassword)) + { + return false; + } + var passwordResult = passwordHasher.VerifyHashedPassword(new User(), sendPassword, userSubmittedPassword); + + return passwordResult is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded; + } } diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 0cf4c22fce..51b658f4c3 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.PasswordValidation; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; using Bit.Core.IdentityServer; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -9,6 +11,8 @@ using Bit.SharedWeb.Utilities; using Duende.IdentityServer.ResponseHandling; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Bit.Identity.Utilities; @@ -57,6 +61,10 @@ public static class ServiceCollectionExtensions .AddExtensionGrantValidator() .AddExtensionGrantValidator(); + // ExtensionGrantValidator Dependencies + services.TryAddScoped, PasswordHasher>(); + services.Configure(options => options.IterationCount = PasswordValidationConstants.PasswordHasherKdfIterations); + if (!globalSettings.SelfHosted) { // Only cloud instances should be able to handle installations diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 9883e6db47..d8f3690679 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.LoginFeatures; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.PasswordValidation; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Auth.Services.Implementations; @@ -443,7 +444,7 @@ public static class ServiceCollectionExtensions this IServiceCollection services, GlobalSettings globalSettings) { services.AddScoped(); - services.Configure(options => options.IterationCount = 100000); + services.Configure(options => options.IterationCount = PasswordValidationConstants.PasswordHasherKdfIterations); services.Configure(options => { options.TokenLifespan = TimeSpan.FromDays(30);