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:
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
// shouldn’t 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())
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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())
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user