1
0
mirror of https://github.com/bitwarden/server synced 2025-12-31 07:33:43 +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:
Ike
2025-09-02 16:48:57 -04:00
committed by GitHub
parent a5bed5dcaa
commit d2d3e0f11b
24 changed files with 1213 additions and 90 deletions

View File

@@ -29,7 +29,7 @@ public class ApiResources
}),
new(ApiScopes.ApiSendAccess, [
JwtClaimTypes.Subject,
Claims.SendId
Claims.SendAccessClaims.SendId
]),
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
using Duende.IdentityServer.Validation;
using Bit.Core.Auth.Identity.TokenProviders;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
@@ -34,7 +35,7 @@ public static class SendAccessConstants
public static class GrantValidatorResults
{
/// <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>
public const string ValidSendGuid = "valid_send_guid";
/// <summary>
@@ -66,8 +67,40 @@ public static class SendAccessConstants
/// </summary>
public const string EmailRequired = "email_required";
/// <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.
/// </summary>
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}";
}
}

View File

@@ -13,7 +13,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendAccessGrantValidator(
ISendAuthenticationQuery _sendAuthenticationQuery,
ISendPasswordRequestValidator _sendPasswordRequestValidator,
ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
IFeatureService _featureService)
: IExtensionGrantValidator
{
@@ -61,16 +62,14 @@ public class SendAccessGrantValidator(
// automatically issue access token
context.Result = BuildBaseSuccessResult(sendIdGuid);
return;
case ResourcePassword rp:
// Validate if the password is correct, or if we need to respond with a 400 stating a password has is required
context.Result = _sendPasswordRequestValidator.ValidateSendPassword(context, rp, sendIdGuid);
// Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required.
context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid);
return;
case EmailOtp eo:
// TODO PM-22678: We will either send the OTP here or validate it based on if otp exists in the request.
// SendOtpToEmail(eo.Emails) or ValidateOtp(eo.Emails);
// break;
// Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure.
context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid);
return;
default:
// shouldnt ever hit this
throw new InvalidOperationException($"Unknown auth method: {method.GetType()}");
@@ -114,28 +113,27 @@ public class SendAccessGrantValidator(
/// <summary>
/// Builds an error result for the specified error type.
/// </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>
private static GrantValidationResult BuildErrorResult(string error)
{
var customResponse = new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, error }
};
return error switch
{
// Request is the wrong shape
SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.MissingSendId],
new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.MissingSendId}
}),
errorDescription: _sendGrantValidatorErrorDescriptions[error],
customResponse),
// Request is correct shape but data is bad
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.InvalidSendId],
new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.InvalidSendId }
}),
errorDescription: _sendGrantValidatorErrorDescriptions[error],
customResponse),
// should never get here
_ => new GrantValidationResult(TokenRequestErrors.InvalidRequest)
};
@@ -145,7 +143,7 @@ public class SendAccessGrantValidator(
{
var claims = new List<Claim>
{
new(Claims.SendId, sendId.ToString()),
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
new(Claims.Type, IdentityClientType.Send.ToString())
};

View File

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

View File

@@ -8,7 +8,7 @@ using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator<ResourcePassword>
{
private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher;
@@ -21,7 +21,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
{ 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 clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword);
@@ -30,13 +30,13 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
if (clientHashedPassword == null)
{
// Request is the wrong shape and doesn't contain a passwordHashB64 field.
return new GrantValidationResult(
return Task.FromResult(new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired],
new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired }
});
}));
}
// _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)
{
// 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,
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch],
new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch }
});
}));
}
return BuildSendPasswordSuccessResult(sendId);
return Task.FromResult(BuildSendPasswordSuccessResult(sendId));
}
/// <summary>
@@ -67,7 +67,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
{
var claims = new List<Claim>
{
new(Claims.SendId, sendId.ToString()),
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
new(Claims.Type, IdentityClientType.Send.ToString())
};