mirror of
https://github.com/bitwarden/server
synced 2025-12-10 13:23:27 +00:00
[PM-22678] Send email otp authentication method (#6255)
feat(auth): email OTP validation, and generalize authentication interface - Generalized send authentication method interface - Made validate method async - Added email mail support for Handlebars - Modified email templates to match future implementation fix(auth): update constants, naming conventions, and error handling - Renamed constants for clarity - Updated claims naming convention - Fixed error message generation - Added customResponse for Rust consumption test(auth): add and fix tests for validators and email - Added tests for SendEmailOtpRequestValidator - Updated tests for SendAccessGrantValidator chore: apply dotnet formatting
This commit is contained in:
@@ -9,12 +9,12 @@ public static class SendAccessClaimsPrincipalExtensions
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
var sendIdClaim = user.FindFirst(Claims.SendId)
|
var sendIdClaim = user.FindFirst(Claims.SendAccessClaims.SendId)
|
||||||
?? throw new InvalidOperationException("Send ID claim not found.");
|
?? throw new InvalidOperationException("send_id claim not found.");
|
||||||
|
|
||||||
if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid))
|
if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Invalid Send ID claim value.");
|
throw new InvalidOperationException("Invalid send_id claim value.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendGuid;
|
return sendGuid;
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public static class Claims
|
|||||||
public const string ManageResetPassword = "manageresetpassword";
|
public const string ManageResetPassword = "manageresetpassword";
|
||||||
public const string ManageScim = "managescim";
|
public const string ManageScim = "managescim";
|
||||||
}
|
}
|
||||||
|
public static class SendAccessClaims
|
||||||
public const string SendId = "send_id";
|
{
|
||||||
|
public const string SendId = "send_id";
|
||||||
|
public const string Email = "send_email";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{#>FullHtmlLayout}}
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
|
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
|
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||||
|
Verify your email to access this Bitwarden Send.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
|
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||||
|
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||||
|
Your verification code is: <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Token}}</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
|
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||||
|
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||||
|
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
|
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||||
|
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||||
|
<hr />
|
||||||
|
{{TheDate}} at {{TheTime}} {{TimeZone}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{/FullHtmlLayout}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{#>BasicTextLayout}}
|
||||||
|
Verify your email to access this Bitwarden Send.
|
||||||
|
|
||||||
|
Your verification code is: {{Token}}
|
||||||
|
|
||||||
|
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
|
||||||
|
|
||||||
|
Date : {{TheDate}} at {{TheTime}} {{TimeZone}}
|
||||||
|
{{/BasicTextLayout}}
|
||||||
12
src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs
Normal file
12
src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Bit.Core.Models.Mail.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send email OTP view model
|
||||||
|
/// </summary>
|
||||||
|
public class DefaultEmailOtpViewModel : BaseMailModel
|
||||||
|
{
|
||||||
|
public string? Token { get; set; }
|
||||||
|
public string? TheDate { get; set; }
|
||||||
|
public string? TheTime { get; set; }
|
||||||
|
public string? TimeZone { get; set; }
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ public interface IMailService
|
|||||||
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
||||||
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
||||||
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
|
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
|
||||||
|
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
|
||||||
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
|
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
|
||||||
Task SendNoMasterPasswordHintEmailAsync(string email);
|
Task SendNoMasterPasswordHintEmailAsync(string email);
|
||||||
Task SendMasterPasswordHintEmailAsync(string email, string hint);
|
Task SendMasterPasswordHintEmailAsync(string email, string hint);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using Bit.Core.Billing.Models.Mail;
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Data.Organizations;
|
using Bit.Core.Models.Data.Organizations;
|
||||||
using Bit.Core.Models.Mail;
|
using Bit.Core.Models.Mail;
|
||||||
|
using Bit.Core.Models.Mail.Auth;
|
||||||
using Bit.Core.Models.Mail.Billing;
|
using Bit.Core.Models.Mail.Billing;
|
||||||
using Bit.Core.Models.Mail.FamiliesForEnterprise;
|
using Bit.Core.Models.Mail.FamiliesForEnterprise;
|
||||||
using Bit.Core.Models.Mail.Provider;
|
using Bit.Core.Models.Mail.Provider;
|
||||||
@@ -199,6 +200,26 @@ public class HandlebarsMailService : IMailService
|
|||||||
await _mailDeliveryService.SendEmailAsync(message);
|
await _mailDeliveryService.SendEmailAsync(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
|
||||||
|
{
|
||||||
|
var message = CreateDefaultMessage(subject, email);
|
||||||
|
var requestDateTime = DateTime.UtcNow;
|
||||||
|
var model = new DefaultEmailOtpViewModel
|
||||||
|
{
|
||||||
|
Token = token,
|
||||||
|
TheDate = requestDateTime.ToLongDateString(),
|
||||||
|
TheTime = requestDateTime.ToShortTimeString(),
|
||||||
|
TimeZone = _utcTimeZoneDisplay,
|
||||||
|
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||||
|
SiteName = _globalSettings.SiteName,
|
||||||
|
};
|
||||||
|
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
|
||||||
|
message.MetaData.Add("SendGridBypassListManagement", true);
|
||||||
|
// TODO - PM-25380 change to string constant
|
||||||
|
message.Category = "SendEmailOtp";
|
||||||
|
await _mailDeliveryService.SendEmailAsync(message);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
||||||
{
|
{
|
||||||
// Check if we've sent this email within the last hour
|
// Check if we've sent this email within the last hour
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ public class NoopMailService : IMailService
|
|||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
|
||||||
|
{
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
||||||
{
|
{
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class ApiResources
|
|||||||
}),
|
}),
|
||||||
new(ApiScopes.ApiSendAccess, [
|
new(ApiScopes.ApiSendAccess, [
|
||||||
JwtClaimTypes.Subject,
|
JwtClaimTypes.Subject,
|
||||||
Claims.SendId
|
Claims.SendAccessClaims.SendId
|
||||||
]),
|
]),
|
||||||
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
|
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
|
||||||
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),
|
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
using Duende.IdentityServer.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
|
|
||||||
|
public interface ISendAuthenticationMethodValidator<T> where T : SendAuthenticationMethod
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">request context</param>
|
||||||
|
/// <param name="authMethod">SendAuthenticationRecord that contains the information to be compared against the context</param>
|
||||||
|
/// <param name="sendId">the sendId being accessed</param>
|
||||||
|
/// <returns>returns the result of the validation; A failed result will be an error a successful will contain the claims and a success</returns>
|
||||||
|
Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, T authMethod, Guid sendId);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using Bit.Core.Tools.Models.Data;
|
|
||||||
using Duende.IdentityServer.Validation;
|
|
||||||
|
|
||||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
|
||||||
|
|
||||||
public interface ISendPasswordRequestValidator
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the send password hash against the client hashed password.
|
|
||||||
/// If this method fails then it will automatically set the context.Result to an invalid grant result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="context">request context</param>
|
|
||||||
/// <param name="resourcePassword">resource password authentication method containing the hash of the Send being retrieved</param>
|
|
||||||
/// <returns>returns the result of the validation; A failed result will be an error a successful will contain the claims and a success</returns>
|
|
||||||
GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Duende.IdentityServer.Validation;
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
using Duende.IdentityServer.Validation;
|
||||||
|
|
||||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ public static class SendAccessConstants
|
|||||||
public static class GrantValidatorResults
|
public static class GrantValidatorResults
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The sendId is valid and the request is well formed.
|
/// The sendId is valid and the request is well formed. Not returned in any response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string ValidSendGuid = "valid_send_guid";
|
public const string ValidSendGuid = "valid_send_guid";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -66,8 +67,40 @@ public static class SendAccessConstants
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string EmailRequired = "email_required";
|
public const string EmailRequired = "email_required";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Represents the error code indicating that an email address is invalid.
|
||||||
|
/// </summary>
|
||||||
|
public const string EmailInvalid = "email_invalid";
|
||||||
|
/// <summary>
|
||||||
/// Represents the status indicating that both email and OTP are required, and the OTP has been sent.
|
/// Represents the status indicating that both email and OTP are required, and the OTP has been sent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string EmailOtpSent = "email_and_otp_required_otp_sent";
|
public const string EmailOtpSent = "email_and_otp_required_otp_sent";
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the status indicating that both email and OTP are required, and the OTP is invalid.
|
||||||
|
/// </summary>
|
||||||
|
public const string EmailOtpInvalid = "otp_invalid";
|
||||||
|
/// <summary>
|
||||||
|
/// For what ever reason the OTP was not able to be generated
|
||||||
|
/// </summary>
|
||||||
|
public const string OtpGenerationFailed = "otp_generation_failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// These are the constants for the OTP token that is generated during the email otp authentication process.
|
||||||
|
/// These items are required by <see cref="IOtpTokenProvider{TOptions}"/> to aid in the creation of a unique lookup key.
|
||||||
|
/// Look up key format is: {TokenProviderName}_{Purpose}_{TokenUniqueIdentifier}
|
||||||
|
/// </summary>
|
||||||
|
public static class OtpToken
|
||||||
|
{
|
||||||
|
public const string TokenProviderName = "send_access";
|
||||||
|
public const string Purpose = "email_otp";
|
||||||
|
/// <summary>
|
||||||
|
/// This will be send_id {0} and email {1}
|
||||||
|
/// </summary>
|
||||||
|
public const string TokenUniqueIdentifier = "{0}_{1}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OtpEmail
|
||||||
|
{
|
||||||
|
public const string Subject = "Your Bitwarden Send verification code is {0}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
|||||||
|
|
||||||
public class SendAccessGrantValidator(
|
public class SendAccessGrantValidator(
|
||||||
ISendAuthenticationQuery _sendAuthenticationQuery,
|
ISendAuthenticationQuery _sendAuthenticationQuery,
|
||||||
ISendPasswordRequestValidator _sendPasswordRequestValidator,
|
ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
|
||||||
|
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
|
||||||
IFeatureService _featureService)
|
IFeatureService _featureService)
|
||||||
: IExtensionGrantValidator
|
: IExtensionGrantValidator
|
||||||
{
|
{
|
||||||
@@ -61,16 +62,14 @@ public class SendAccessGrantValidator(
|
|||||||
// automatically issue access token
|
// automatically issue access token
|
||||||
context.Result = BuildBaseSuccessResult(sendIdGuid);
|
context.Result = BuildBaseSuccessResult(sendIdGuid);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case ResourcePassword rp:
|
case ResourcePassword rp:
|
||||||
// Validate if the password is correct, or if we need to respond with a 400 stating a password has is required
|
// Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required.
|
||||||
context.Result = _sendPasswordRequestValidator.ValidateSendPassword(context, rp, sendIdGuid);
|
context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid);
|
||||||
return;
|
return;
|
||||||
case EmailOtp eo:
|
case EmailOtp eo:
|
||||||
// TODO PM-22678: We will either send the OTP here or validate it based on if otp exists in the request.
|
// Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure.
|
||||||
// SendOtpToEmail(eo.Emails) or ValidateOtp(eo.Emails);
|
context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid);
|
||||||
// break;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// shouldn’t ever hit this
|
// shouldn’t ever hit this
|
||||||
throw new InvalidOperationException($"Unknown auth method: {method.GetType()}");
|
throw new InvalidOperationException($"Unknown auth method: {method.GetType()}");
|
||||||
@@ -114,28 +113,27 @@ public class SendAccessGrantValidator(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds an error result for the specified error type.
|
/// Builds an error result for the specified error type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="error">The error type.</param>
|
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.GrantValidatorResults"/></param>
|
||||||
/// <returns>The error result.</returns>
|
/// <returns>The error result.</returns>
|
||||||
private static GrantValidationResult BuildErrorResult(string error)
|
private static GrantValidationResult BuildErrorResult(string error)
|
||||||
{
|
{
|
||||||
|
var customResponse = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ SendAccessConstants.SendAccessError, error }
|
||||||
|
};
|
||||||
|
|
||||||
return error switch
|
return error switch
|
||||||
{
|
{
|
||||||
// Request is the wrong shape
|
// Request is the wrong shape
|
||||||
SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult(
|
SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult(
|
||||||
TokenRequestErrors.InvalidRequest,
|
TokenRequestErrors.InvalidRequest,
|
||||||
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.MissingSendId],
|
errorDescription: _sendGrantValidatorErrorDescriptions[error],
|
||||||
new Dictionary<string, object>
|
customResponse),
|
||||||
{
|
|
||||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.MissingSendId}
|
|
||||||
}),
|
|
||||||
// Request is correct shape but data is bad
|
// Request is correct shape but data is bad
|
||||||
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
|
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
|
||||||
TokenRequestErrors.InvalidGrant,
|
TokenRequestErrors.InvalidGrant,
|
||||||
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.InvalidSendId],
|
errorDescription: _sendGrantValidatorErrorDescriptions[error],
|
||||||
new Dictionary<string, object>
|
customResponse),
|
||||||
{
|
|
||||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.InvalidSendId }
|
|
||||||
}),
|
|
||||||
// should never get here
|
// should never get here
|
||||||
_ => new GrantValidationResult(TokenRequestErrors.InvalidRequest)
|
_ => new GrantValidationResult(TokenRequestErrors.InvalidRequest)
|
||||||
};
|
};
|
||||||
@@ -145,7 +143,7 @@ public class SendAccessGrantValidator(
|
|||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(Claims.SendId, sendId.ToString()),
|
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
|
||||||
new(Claims.Type, IdentityClientType.Send.ToString())
|
new(Claims.Type, IdentityClientType.Send.ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
using Bit.Core.Identity;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
using Bit.Identity.IdentityServer.Enums;
|
||||||
|
using Duende.IdentityServer.Models;
|
||||||
|
using Duende.IdentityServer.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
|
|
||||||
|
public class SendEmailOtpRequestValidator(
|
||||||
|
IOtpTokenProvider<DefaultOtpTokenProviderOptions> otpTokenProvider,
|
||||||
|
IMailService mailService) : ISendAuthenticationMethodValidator<EmailOtp>
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// static object that contains the error messages for the SendEmailOtpRequestValidator.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> _sendEmailOtpValidatorErrorDescriptions = new()
|
||||||
|
{
|
||||||
|
{ SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." },
|
||||||
|
{ SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent, "email otp sent." },
|
||||||
|
{ SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, $"{SendAccessConstants.TokenRequest.Email} is invalid." },
|
||||||
|
{ SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." },
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, EmailOtp authMethod, Guid sendId)
|
||||||
|
{
|
||||||
|
var request = context.Request.Raw;
|
||||||
|
// get email
|
||||||
|
var email = request.Get(SendAccessConstants.TokenRequest.Email);
|
||||||
|
|
||||||
|
// It is an invalid request if the email is missing which indicated bad shape.
|
||||||
|
if (string.IsNullOrEmpty(email))
|
||||||
|
{
|
||||||
|
// Request is the wrong shape and doesn't contain an email field.
|
||||||
|
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
// email must be in the list of emails in the EmailOtp array
|
||||||
|
if (!authMethod.Emails.Contains(email))
|
||||||
|
{
|
||||||
|
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get otp from request
|
||||||
|
var requestOtp = request.Get(SendAccessConstants.TokenRequest.Otp);
|
||||||
|
var uniqueIdentifierForTokenCache = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||||
|
if (string.IsNullOrEmpty(requestOtp))
|
||||||
|
{
|
||||||
|
// Since the request doesn't have an OTP, generate one
|
||||||
|
var token = await otpTokenProvider.GenerateTokenAsync(
|
||||||
|
SendAccessConstants.OtpToken.TokenProviderName,
|
||||||
|
SendAccessConstants.OtpToken.Purpose,
|
||||||
|
uniqueIdentifierForTokenCache);
|
||||||
|
|
||||||
|
// Verify that the OTP is generated
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailService.SendSendEmailOtpEmailAsync(
|
||||||
|
email,
|
||||||
|
token,
|
||||||
|
string.Format(SendAccessConstants.OtpEmail.Subject, token));
|
||||||
|
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate request otp
|
||||||
|
var otpResult = await otpTokenProvider.ValidateTokenAsync(
|
||||||
|
requestOtp,
|
||||||
|
SendAccessConstants.OtpToken.TokenProviderName,
|
||||||
|
SendAccessConstants.OtpToken.Purpose,
|
||||||
|
uniqueIdentifierForTokenCache);
|
||||||
|
|
||||||
|
// If OTP is invalid return error result
|
||||||
|
if (!otpResult)
|
||||||
|
{
|
||||||
|
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildSuccessResult(sendId, email!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GrantValidationResult BuildErrorResult(string error)
|
||||||
|
{
|
||||||
|
switch (error)
|
||||||
|
{
|
||||||
|
case SendAccessConstants.EmailOtpValidatorResults.EmailRequired:
|
||||||
|
case SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent:
|
||||||
|
return new GrantValidationResult(TokenRequestErrors.InvalidRequest,
|
||||||
|
errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ SendAccessConstants.SendAccessError, error }
|
||||||
|
});
|
||||||
|
case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid:
|
||||||
|
case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid:
|
||||||
|
return new GrantValidationResult(
|
||||||
|
TokenRequestErrors.InvalidGrant,
|
||||||
|
errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ SendAccessConstants.SendAccessError, error }
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return new GrantValidationResult(
|
||||||
|
TokenRequestErrors.InvalidRequest,
|
||||||
|
errorDescription: error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a successful validation result for the Send password send_access grant.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sendId">Guid of the send being accessed.</param>
|
||||||
|
/// <returns>successful grant validation result</returns>
|
||||||
|
private static GrantValidationResult BuildSuccessResult(Guid sendId, string email)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
|
||||||
|
new(Claims.SendAccessClaims.Email, email),
|
||||||
|
new(Claims.Type, IdentityClientType.Send.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GrantValidationResult(
|
||||||
|
subject: sendId.ToString(),
|
||||||
|
authenticationMethod: CustomGrantTypes.SendAccess,
|
||||||
|
claims: claims);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ using Duende.IdentityServer.Validation;
|
|||||||
|
|
||||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
|
|
||||||
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator
|
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator<ResourcePassword>
|
||||||
{
|
{
|
||||||
private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher;
|
private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher;
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
|||||||
{ SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." }
|
{ SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." }
|
||||||
};
|
};
|
||||||
|
|
||||||
public GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId)
|
public Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId)
|
||||||
{
|
{
|
||||||
var request = context.Request.Raw;
|
var request = context.Request.Raw;
|
||||||
var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword);
|
var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword);
|
||||||
@@ -30,13 +30,13 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
|||||||
if (clientHashedPassword == null)
|
if (clientHashedPassword == null)
|
||||||
{
|
{
|
||||||
// Request is the wrong shape and doesn't contain a passwordHashB64 field.
|
// Request is the wrong shape and doesn't contain a passwordHashB64 field.
|
||||||
return new GrantValidationResult(
|
return Task.FromResult(new GrantValidationResult(
|
||||||
TokenRequestErrors.InvalidRequest,
|
TokenRequestErrors.InvalidRequest,
|
||||||
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired],
|
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired],
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired }
|
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired }
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call.
|
// _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call.
|
||||||
@@ -46,16 +46,16 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
|||||||
if (!hashMatches)
|
if (!hashMatches)
|
||||||
{
|
{
|
||||||
// Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty.
|
// Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty.
|
||||||
return new GrantValidationResult(
|
return Task.FromResult(new GrantValidationResult(
|
||||||
TokenRequestErrors.InvalidGrant,
|
TokenRequestErrors.InvalidGrant,
|
||||||
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch],
|
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch],
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch }
|
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch }
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildSendPasswordSuccessResult(sendId);
|
return Task.FromResult(BuildSendPasswordSuccessResult(sendId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,7 +67,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
|||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(Claims.SendId, sendId.ToString()),
|
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
|
||||||
new(Claims.Type, IdentityClientType.Send.ToString())
|
new(Claims.Type, IdentityClientType.Send.ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Identity.IdentityServer;
|
using Bit.Identity.IdentityServer;
|
||||||
using Bit.Identity.IdentityServer.ClientProviders;
|
using Bit.Identity.IdentityServer.ClientProviders;
|
||||||
@@ -26,7 +27,8 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
||||||
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
||||||
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
||||||
services.AddTransient<ISendPasswordRequestValidator, SendPasswordRequestValidator>();
|
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
|
||||||
|
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();
|
||||||
|
|
||||||
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
|
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
|
||||||
var identityServerBuilder = services
|
var identityServerBuilder = services
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public class SendAccessClaimsPrincipalExtensionsTests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var guid = Guid.NewGuid();
|
var guid = Guid.NewGuid();
|
||||||
var claims = new[] { new Claim(Claims.SendId, guid.ToString()) };
|
var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, guid.ToString()) };
|
||||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -30,19 +30,19 @@ public class SendAccessClaimsPrincipalExtensionsTests
|
|||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
|
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
|
||||||
Assert.Equal("Send ID claim not found.", ex.Message);
|
Assert.Equal("send_id claim not found.", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid()
|
public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var claims = new[] { new Claim(Claims.SendId, "not-a-guid") };
|
var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, "not-a-guid") };
|
||||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
|
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
|
||||||
Assert.Equal("Invalid Send ID claim value.", ex.Message);
|
Assert.Equal("Invalid send_id claim value.", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -247,11 +247,18 @@ public class HandlebarsMailServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove this test when we add actual tests. It only proves that
|
|
||||||
// we've properly constructed the system under test.
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ServiceExists()
|
public async Task SendSendEmailOtpEmailAsync_SendsEmail()
|
||||||
{
|
{
|
||||||
Assert.NotNull(_sut);
|
// Arrange
|
||||||
|
var email = "test@example.com";
|
||||||
|
var token = "aToken";
|
||||||
|
var subject = string.Format("Your Bitwarden Send verification code is {0}", token);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.SendSendEmailOtpEmailAsync(email, token, subject);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any<MailMessage>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
|
|||||||
services.AddSingleton(sendAuthQuery);
|
services.AddSingleton(sendAuthQuery);
|
||||||
|
|
||||||
// Mock password validator to return success
|
// Mock password validator to return success
|
||||||
var passwordValidator = Substitute.For<ISendPasswordRequestValidator>();
|
var passwordValidator = Substitute.For<ISendAuthenticationMethodValidator<ResourcePassword>>();
|
||||||
passwordValidator.ValidateSendPassword(
|
passwordValidator.ValidateRequestAsync(
|
||||||
Arg.Any<ExtensionGrantValidationContext>(),
|
Arg.Any<ExtensionGrantValidationContext>(),
|
||||||
Arg.Any<ResourcePassword>(),
|
Arg.Any<ResourcePassword>(),
|
||||||
Arg.Any<Guid>())
|
Arg.Any<Guid>())
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.IdentityServer;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.Identity.IdentityServer.Enums;
|
||||||
|
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
|
using Bit.IntegrationTestCommon.Factories;
|
||||||
|
using Duende.IdentityModel;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Identity.IntegrationTest.RequestValidation;
|
||||||
|
|
||||||
|
public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<IdentityApplicationFactory>
|
||||||
|
{
|
||||||
|
private readonly IdentityApplicationFactory _factory;
|
||||||
|
|
||||||
|
public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sendId = Guid.NewGuid();
|
||||||
|
var client = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
var featureService = Substitute.For<IFeatureService>();
|
||||||
|
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||||
|
services.AddSingleton(featureService);
|
||||||
|
|
||||||
|
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||||
|
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||||
|
.Returns(new EmailOtp(["test@example.com"]));
|
||||||
|
services.AddSingleton(sendAuthQuery);
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
var requestBody = CreateTokenRequestBody(sendId); // No email
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync("/connect/token", requestBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
|
||||||
|
Assert.Contains("email is required", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAccess_EmailOtpProtectedSend_EmailWithoutOtp_SendsOtpEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sendId = Guid.NewGuid();
|
||||||
|
var email = "test@example.com";
|
||||||
|
var generatedToken = "123456";
|
||||||
|
|
||||||
|
var client = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
var featureService = Substitute.For<IFeatureService>();
|
||||||
|
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||||
|
services.AddSingleton(featureService);
|
||||||
|
|
||||||
|
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||||
|
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||||
|
.Returns(new EmailOtp([email]));
|
||||||
|
services.AddSingleton(sendAuthQuery);
|
||||||
|
|
||||||
|
// Mock OTP token provider
|
||||||
|
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||||
|
otpProvider.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(generatedToken);
|
||||||
|
services.AddSingleton(otpProvider);
|
||||||
|
|
||||||
|
// Mock mail service
|
||||||
|
var mailService = Substitute.For<IMailService>();
|
||||||
|
services.AddSingleton(mailService);
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync("/connect/token", requestBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
|
||||||
|
Assert.Contains("email otp sent", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAccess_EmailOtpProtectedSend_ValidOtp_ReturnsAccessToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sendId = Guid.NewGuid();
|
||||||
|
var email = "test@example.com";
|
||||||
|
var otp = "123456";
|
||||||
|
|
||||||
|
var client = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
var featureService = Substitute.For<IFeatureService>();
|
||||||
|
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||||
|
services.AddSingleton(featureService);
|
||||||
|
|
||||||
|
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||||
|
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||||
|
.Returns(new EmailOtp(new[] { email }));
|
||||||
|
services.AddSingleton(sendAuthQuery);
|
||||||
|
|
||||||
|
// Mock OTP token provider to validate successfully
|
||||||
|
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||||
|
otpProvider.ValidateTokenAsync(otp, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(true);
|
||||||
|
services.AddSingleton(otpProvider);
|
||||||
|
|
||||||
|
var mailService = Substitute.For<IMailService>();
|
||||||
|
services.AddSingleton(mailService);
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: otp);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync("/connect/token", requestBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(response.IsSuccessStatusCode);
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains(OidcConstants.TokenResponse.AccessToken, content);
|
||||||
|
Assert.Contains(OidcConstants.TokenResponse.BearerTokenType, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidGrant()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sendId = Guid.NewGuid();
|
||||||
|
var email = "test@example.com";
|
||||||
|
var invalidOtp = "wrong123";
|
||||||
|
|
||||||
|
var client = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
var featureService = Substitute.For<IFeatureService>();
|
||||||
|
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||||
|
services.AddSingleton(featureService);
|
||||||
|
|
||||||
|
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||||
|
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||||
|
.Returns(new EmailOtp(new[] { email }));
|
||||||
|
services.AddSingleton(sendAuthQuery);
|
||||||
|
|
||||||
|
// Mock OTP token provider to validate as false
|
||||||
|
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||||
|
otpProvider.ValidateTokenAsync(invalidOtp, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(false);
|
||||||
|
services.AddSingleton(otpProvider);
|
||||||
|
|
||||||
|
var mailService = Substitute.For<IMailService>();
|
||||||
|
services.AddSingleton(mailService);
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: invalidOtp);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync("/connect/token", requestBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content);
|
||||||
|
Assert.Contains("email otp is invalid", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAccess_EmailOtpProtectedSend_OtpGenerationFails_ReturnsInvalidRequest()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sendId = Guid.NewGuid();
|
||||||
|
var email = "test@example.com";
|
||||||
|
|
||||||
|
var client = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
var featureService = Substitute.For<IFeatureService>();
|
||||||
|
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||||
|
services.AddSingleton(featureService);
|
||||||
|
|
||||||
|
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||||
|
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||||
|
.Returns(new EmailOtp(new[] { email }));
|
||||||
|
services.AddSingleton(sendAuthQuery);
|
||||||
|
|
||||||
|
// Mock OTP token provider to fail generation
|
||||||
|
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||||
|
otpProvider.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns((string)null);
|
||||||
|
services.AddSingleton(otpProvider);
|
||||||
|
|
||||||
|
var mailService = Substitute.For<IMailService>();
|
||||||
|
services.AddSingleton(mailService);
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync("/connect/token", requestBody);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormUrlEncodedContent CreateTokenRequestBody(Guid sendId,
|
||||||
|
string sendEmail = null, string emailOtp = null)
|
||||||
|
{
|
||||||
|
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
|
||||||
|
var parameters = new List<KeyValuePair<string, string>>
|
||||||
|
{
|
||||||
|
new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
|
||||||
|
new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ),
|
||||||
|
new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess),
|
||||||
|
new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()),
|
||||||
|
new(SendAccessConstants.TokenRequest.SendId, sendIdBase64)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(sendEmail))
|
||||||
|
{
|
||||||
|
parameters.Add(new KeyValuePair<string, string>(
|
||||||
|
SendAccessConstants.TokenRequest.Email, sendEmail));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(emailOtp))
|
||||||
|
{
|
||||||
|
parameters.Add(new KeyValuePair<string, string>(
|
||||||
|
SendAccessConstants.TokenRequest.Otp, emailOtp));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FormUrlEncodedContent(parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ using Duende.IdentityServer.Validation;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Bit.Identity.Test.IdentityServer;
|
namespace Bit.Identity.Test.IdentityServer.SendAccess;
|
||||||
|
|
||||||
[SutProviderCustomize]
|
[SutProviderCustomize]
|
||||||
public class SendAccessGrantValidatorTests
|
public class SendAccessGrantValidatorTests
|
||||||
@@ -167,7 +167,7 @@ public class SendAccessGrantValidatorTests
|
|||||||
// get the claims from the subject
|
// get the claims from the subject
|
||||||
var claims = subject.Claims.ToList();
|
var claims = subject.Claims.ToList();
|
||||||
Assert.NotEmpty(claims);
|
Assert.NotEmpty(claims);
|
||||||
Assert.Contains(claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString());
|
Assert.Contains(claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||||
Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,8 +189,8 @@ public class SendAccessGrantValidatorTests
|
|||||||
.GetAuthenticationMethod(sendId)
|
.GetAuthenticationMethod(sendId)
|
||||||
.Returns(resourcePassword);
|
.Returns(resourcePassword);
|
||||||
|
|
||||||
sutProvider.GetDependency<ISendPasswordRequestValidator>()
|
sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
|
||||||
.ValidateSendPassword(context, resourcePassword, sendId)
|
.ValidateRequestAsync(context, resourcePassword, sendId)
|
||||||
.Returns(expectedResult);
|
.Returns(expectedResult);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -198,15 +198,16 @@ public class SendAccessGrantValidatorTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(expectedResult, context.Result);
|
Assert.Equal(expectedResult, context.Result);
|
||||||
sutProvider.GetDependency<ISendPasswordRequestValidator>()
|
await sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.ValidateSendPassword(context, resourcePassword, sendId);
|
.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError(
|
public async Task ValidateAsync_EmailOtpMethod_CallsEmailOtp(
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
SutProvider<SendAccessGrantValidator> sutProvider,
|
SutProvider<SendAccessGrantValidator> sutProvider,
|
||||||
|
GrantValidationResult expectedResult,
|
||||||
Guid sendId,
|
Guid sendId,
|
||||||
EmailOtp emailOtp)
|
EmailOtp emailOtp)
|
||||||
{
|
{
|
||||||
@@ -216,15 +217,22 @@ public class SendAccessGrantValidatorTests
|
|||||||
sendId,
|
sendId,
|
||||||
tokenRequest);
|
tokenRequest);
|
||||||
|
|
||||||
|
|
||||||
sutProvider.GetDependency<ISendAuthenticationQuery>()
|
sutProvider.GetDependency<ISendAuthenticationQuery>()
|
||||||
.GetAuthenticationMethod(sendId)
|
.GetAuthenticationMethod(sendId)
|
||||||
.Returns(emailOtp);
|
.Returns(emailOtp);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
|
||||||
|
.ValidateRequestAsync(context, emailOtp, sendId)
|
||||||
|
.Returns(expectedResult);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
await sutProvider.Sut.ValidateAsync(context);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
// Currently the EmailOtp case doesn't set a result, so it should be null
|
Assert.Equal(expectedResult, context.Result);
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.ValidateAsync(context));
|
await sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
|
||||||
|
.Received(1)
|
||||||
|
.ValidateRequestAsync(context, emailOtp, sendId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -256,7 +264,7 @@ public class SendAccessGrantValidatorTests
|
|||||||
public void GrantType_ReturnsCorrectType()
|
public void GrantType_ReturnsCorrectType()
|
||||||
{
|
{
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
var validator = new SendAccessGrantValidator(null!, null!, null!);
|
var validator = new SendAccessGrantValidator(null!, null!, null!, null!);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType);
|
Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType);
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Identity;
|
||||||
|
using Bit.Core.IdentityServer;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.Identity.IdentityServer.Enums;
|
||||||
|
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Duende.IdentityModel;
|
||||||
|
using Duende.IdentityServer.Validation;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Identity.Test.IdentityServer.SendAccess;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class SendEmailOtpRequestValidatorTests
|
||||||
|
{
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateRequestAsync_MissingEmail_ReturnsInvalidRequest(
|
||||||
|
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
EmailOtp emailOtp,
|
||||||
|
Guid sendId)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId);
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||||
|
Assert.Equal("email is required.", result.ErrorDescription);
|
||||||
|
|
||||||
|
// Verify no OTP generation or email sending occurred
|
||||||
|
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateRequestAsync_EmailNotInList_ReturnsInvalidRequest(
|
||||||
|
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
EmailOtp emailOtp,
|
||||||
|
string email,
|
||||||
|
Guid sendId)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
|
||||||
|
var emailOTP = new EmailOtp(["user@test.dev"]);
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||||
|
Assert.Equal("email is invalid.", result.ErrorDescription);
|
||||||
|
|
||||||
|
// Verify no OTP generation or email sending occurred
|
||||||
|
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp(
|
||||||
|
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
EmailOtp emailOtp,
|
||||||
|
Guid sendId,
|
||||||
|
string email,
|
||||||
|
string generatedToken)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.GenerateTokenAsync(
|
||||||
|
SendAccessConstants.OtpToken.TokenProviderName,
|
||||||
|
SendAccessConstants.OtpToken.Purpose,
|
||||||
|
expectedUniqueId)
|
||||||
|
.Returns(generatedToken);
|
||||||
|
|
||||||
|
emailOtp = emailOtp with { Emails = [email] };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||||
|
Assert.Equal("email otp sent.", result.ErrorDescription);
|
||||||
|
|
||||||
|
// Verify OTP generation
|
||||||
|
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.Received(1)
|
||||||
|
.GenerateTokenAsync(
|
||||||
|
SendAccessConstants.OtpToken.TokenProviderName,
|
||||||
|
SendAccessConstants.OtpToken.Purpose,
|
||||||
|
expectedUniqueId);
|
||||||
|
|
||||||
|
// Verify email sending
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.Received(1)
|
||||||
|
.SendSendEmailOtpEmailAsync(email, generatedToken, Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateRequestAsync_OtpGenerationFails_ReturnsGenerationFailedError(
|
||||||
|
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
EmailOtp emailOtp,
|
||||||
|
Guid sendId,
|
||||||
|
string email)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
emailOtp = emailOtp with { Emails = [email] };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns((string)null); // Generation fails
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||||
|
|
||||||
|
// Verify no email was sent
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess(
|
||||||
|
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
EmailOtp emailOtp,
|
||||||
|
Guid sendId,
|
||||||
|
string email,
|
||||||
|
string otp)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp);
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
emailOtp = emailOtp with { Emails = [email] };
|
||||||
|
|
||||||
|
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.ValidateTokenAsync(
|
||||||
|
otp,
|
||||||
|
SendAccessConstants.OtpToken.TokenProviderName,
|
||||||
|
SendAccessConstants.OtpToken.Purpose,
|
||||||
|
expectedUniqueId)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
var sub = result.Subject;
|
||||||
|
Assert.Equal(sendId.ToString(), sub.Claims.First(c => c.Type == Claims.SendAccessClaims.SendId).Value);
|
||||||
|
|
||||||
|
// Verify claims
|
||||||
|
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||||
|
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.Email && c.Value == email);
|
||||||
|
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||||
|
|
||||||
|
// Verify OTP validation was called
|
||||||
|
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.Received(1)
|
||||||
|
.ValidateTokenAsync(otp, SendAccessConstants.OtpToken.TokenProviderName, SendAccessConstants.OtpToken.Purpose, expectedUniqueId);
|
||||||
|
|
||||||
|
// Verify no email was sent (validation only)
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant(
|
||||||
|
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
EmailOtp emailOtp,
|
||||||
|
Guid sendId,
|
||||||
|
string email,
|
||||||
|
string invalidOtp)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp);
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
emailOtp = emailOtp with { Emails = [email] };
|
||||||
|
|
||||||
|
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.ValidateTokenAsync(invalidOtp,
|
||||||
|
SendAccessConstants.OtpToken.TokenProviderName,
|
||||||
|
SendAccessConstants.OtpToken.Purpose,
|
||||||
|
expectedUniqueId)
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||||
|
Assert.Equal("email otp is invalid.", result.ErrorDescription);
|
||||||
|
|
||||||
|
// Verify OTP validation was attempted
|
||||||
|
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||||
|
.Received(1)
|
||||||
|
.ValidateTokenAsync(invalidOtp,
|
||||||
|
SendAccessConstants.OtpToken.TokenProviderName,
|
||||||
|
SendAccessConstants.OtpToken.Purpose,
|
||||||
|
expectedUniqueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WithValidParameters_CreatesInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var otpTokenProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||||
|
var mailService = Substitute.For<IMailService>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NameValueCollection CreateValidatedTokenRequest(
|
||||||
|
Guid sendId,
|
||||||
|
string sendEmail = null,
|
||||||
|
string otpCode = null)
|
||||||
|
{
|
||||||
|
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
|
||||||
|
|
||||||
|
var rawRequestParameters = new NameValueCollection
|
||||||
|
{
|
||||||
|
{ OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess },
|
||||||
|
{ OidcConstants.TokenRequest.ClientId, BitwardenClient.Send },
|
||||||
|
{ OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess },
|
||||||
|
{ "device_type", ((int)DeviceType.FirefoxBrowser).ToString() },
|
||||||
|
{ SendAccessConstants.TokenRequest.SendId, sendIdBase64 }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sendEmail != null)
|
||||||
|
{
|
||||||
|
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otpCode != null && sendEmail != null)
|
||||||
|
{
|
||||||
|
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawRequestParameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using Bit.Core.Auth.UserFeatures.SendAccess;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Identity;
|
||||||
|
using Bit.Core.IdentityServer;
|
||||||
|
using Bit.Core.KeyManagement.Sends;
|
||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.Identity.IdentityServer.Enums;
|
||||||
|
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Duende.IdentityModel;
|
||||||
|
using Duende.IdentityServer.Validation;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Identity.Test.IdentityServer.SendAccess;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class SendPasswordRequestValidatorTests
|
||||||
|
{
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
|
||||||
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
ResourcePassword resourcePassword,
|
||||||
|
Guid sendId)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId);
|
||||||
|
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||||
|
Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required.", result.ErrorDescription);
|
||||||
|
|
||||||
|
// Verify password hasher was not called
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.PasswordHashMatches(Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
|
||||||
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
ResourcePassword resourcePassword,
|
||||||
|
Guid sendId,
|
||||||
|
string clientPasswordHash)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
|
||||||
|
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash)
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||||
|
Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is invalid.", result.ErrorDescription);
|
||||||
|
|
||||||
|
// Verify password hasher was called with correct parameters
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.Received(1)
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
|
||||||
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
ResourcePassword resourcePassword,
|
||||||
|
Guid sendId,
|
||||||
|
string clientPasswordHash)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
|
||||||
|
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
|
||||||
|
var sub = result.Subject;
|
||||||
|
Assert.Equal(sendId, sub.GetSendId());
|
||||||
|
|
||||||
|
// Verify claims
|
||||||
|
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||||
|
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||||
|
|
||||||
|
// Verify password hasher was called
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.Received(1)
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
|
||||||
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
ResourcePassword resourcePassword,
|
||||||
|
Guid sendId)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty);
|
||||||
|
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, string.Empty)
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||||
|
|
||||||
|
// Verify password hasher was called with empty string
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.Received(1)
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
|
||||||
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
ResourcePassword resourcePassword,
|
||||||
|
Guid sendId)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var whitespacePassword = " ";
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword);
|
||||||
|
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, whitespacePassword)
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
|
||||||
|
// Verify password hasher was called with whitespace string
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.Received(1)
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, whitespacePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
|
||||||
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
ResourcePassword resourcePassword,
|
||||||
|
Guid sendId)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var firstPassword = "first-password";
|
||||||
|
var secondPassword = "second-password";
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword);
|
||||||
|
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, firstPassword)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||||
|
|
||||||
|
// Verify password hasher was called with first value
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.Received(1)
|
||||||
|
.PasswordHashMatches(resourcePassword.Hash, $"{firstPassword},{secondPassword}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
|
||||||
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
ResourcePassword resourcePassword,
|
||||||
|
Guid sendId,
|
||||||
|
string clientPasswordHash)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
|
||||||
|
|
||||||
|
var context = new ExtensionGrantValidationContext
|
||||||
|
{
|
||||||
|
Request = tokenRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||||
|
.PasswordHashMatches(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
var sub = result.Subject;
|
||||||
|
|
||||||
|
var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId);
|
||||||
|
Assert.NotNull(sendIdClaim);
|
||||||
|
Assert.Equal(sendId.ToString(), sendIdClaim.Value);
|
||||||
|
|
||||||
|
var typeClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.Type);
|
||||||
|
Assert.NotNull(typeClaim);
|
||||||
|
Assert.Equal(IdentityClientType.Send.ToString(), typeClaim.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WithValidParameters_CreatesInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sendPasswordHasher = Substitute.For<ISendPasswordHasher>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var validator = new SendPasswordRequestValidator(sendPasswordHasher);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NameValueCollection CreateValidatedTokenRequest(
|
||||||
|
Guid sendId,
|
||||||
|
params string[] passwordHash)
|
||||||
|
{
|
||||||
|
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
|
||||||
|
|
||||||
|
var rawRequestParameters = new NameValueCollection
|
||||||
|
{
|
||||||
|
{ OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess },
|
||||||
|
{ OidcConstants.TokenRequest.ClientId, BitwardenClient.Send },
|
||||||
|
{ OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess },
|
||||||
|
{ "device_type", ((int)DeviceType.FirefoxBrowser).ToString() },
|
||||||
|
{ SendAccessConstants.TokenRequest.SendId, sendIdBase64 }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (passwordHash != null && passwordHash.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var hash in passwordHash)
|
||||||
|
{
|
||||||
|
rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawRequestParameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ namespace Bit.Identity.Test.IdentityServer;
|
|||||||
public class SendPasswordRequestValidatorTests
|
public class SendPasswordRequestValidatorTests
|
||||||
{
|
{
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public void ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
|
public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
|
||||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
ResourcePassword resourcePassword,
|
ResourcePassword resourcePassword,
|
||||||
@@ -36,7 +36,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsError);
|
Assert.True(result.IsError);
|
||||||
@@ -50,7 +50,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public void ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
|
public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
|
||||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
ResourcePassword resourcePassword,
|
ResourcePassword resourcePassword,
|
||||||
@@ -70,7 +70,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
.Returns(false);
|
.Returns(false);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsError);
|
Assert.True(result.IsError);
|
||||||
@@ -84,7 +84,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public void ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
|
public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
|
||||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
ResourcePassword resourcePassword,
|
ResourcePassword resourcePassword,
|
||||||
@@ -104,7 +104,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
.Returns(true);
|
.Returns(true);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
@@ -113,7 +113,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
Assert.Equal(sendId, sub.GetSendId());
|
Assert.Equal(sendId, sub.GetSendId());
|
||||||
|
|
||||||
// Verify claims
|
// Verify claims
|
||||||
Assert.Contains(sub.Claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString());
|
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||||
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||||
|
|
||||||
// Verify password hasher was called
|
// Verify password hasher was called
|
||||||
@@ -123,7 +123,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public void ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
|
public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
|
||||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
ResourcePassword resourcePassword,
|
ResourcePassword resourcePassword,
|
||||||
@@ -142,7 +142,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
.Returns(false);
|
.Returns(false);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsError);
|
Assert.True(result.IsError);
|
||||||
@@ -155,7 +155,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public void ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
|
public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
|
||||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
ResourcePassword resourcePassword,
|
ResourcePassword resourcePassword,
|
||||||
@@ -175,7 +175,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
.Returns(false);
|
.Returns(false);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsError);
|
Assert.True(result.IsError);
|
||||||
@@ -187,7 +187,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public void ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
|
public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
|
||||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
ResourcePassword resourcePassword,
|
ResourcePassword resourcePassword,
|
||||||
@@ -208,7 +208,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
.Returns(true);
|
.Returns(true);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsError);
|
Assert.True(result.IsError);
|
||||||
@@ -221,7 +221,7 @@ public class SendPasswordRequestValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public void ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
|
public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
|
||||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
ResourcePassword resourcePassword,
|
ResourcePassword resourcePassword,
|
||||||
@@ -241,13 +241,13 @@ public class SendPasswordRequestValidatorTests
|
|||||||
.Returns(true);
|
.Returns(true);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
var sub = result.Subject;
|
var sub = result.Subject;
|
||||||
|
|
||||||
var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendId);
|
var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId);
|
||||||
Assert.NotNull(sendIdClaim);
|
Assert.NotNull(sendIdClaim);
|
||||||
Assert.Equal(sendId.ToString(), sendIdClaim.Value);
|
Assert.Equal(sendId.ToString(), sendIdClaim.Value);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user