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

Auth/PM-6198 - Registration with Email Verification - Add email clicked endpoint (#4520)

* PM-6198 - RegistrationEmailVerificationTokenable - add new static validate token method

* PM-6198 - Rename RegistrationStart to Registration as we now have to add another anonymous reference event.

* PM-6198 - rest of work

* PM-6198 - Unit test new account controller method.

* PM-6198 - Integration test new account controller endpoint
This commit is contained in:
Jared Snider 2024-07-22 17:24:42 -04:00 committed by GitHub
parent 45b99336da
commit 1b5f9e3f3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 229 additions and 6 deletions

View File

@ -0,0 +1,17 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
public class RegisterVerificationEmailClickedRequestModel
{
[Required]
[StrictEmailAddress]
[StringLength(256)]
public string Email { get; set; }
[Required]
public string EmailVerificationToken { get; set; }
}

View File

@ -55,4 +55,12 @@ public class RegistrationEmailVerificationTokenable : ExpiringTokenable
&& !string.IsNullOrWhiteSpace(Email); && !string.IsNullOrWhiteSpace(Email);
public static bool ValidateToken(IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> dataProtectorTokenFactory, string token, string userEmail)
{
return dataProtectorTokenFactory.TryUnprotect(token, out var tokenable)
&& tokenable.Valid
&& tokenable.TokenIsValid(userEmail);
}
} }

View File

@ -10,6 +10,6 @@ public enum ReferenceEventSource
User, User,
[EnumMember(Value = "provider")] [EnumMember(Value = "provider")]
Provider, Provider,
[EnumMember(Value = "registrationStart")] [EnumMember(Value = "registration")]
RegistrationStart, Registration,
} }

View File

@ -6,6 +6,8 @@ public enum ReferenceEventType
{ {
[EnumMember(Value = "signup-email-submit")] [EnumMember(Value = "signup-email-submit")]
SignupEmailSubmit, SignupEmailSubmit,
[EnumMember(Value = "signup-email-clicked")]
SignupEmailClicked,
[EnumMember(Value = "signup")] [EnumMember(Value = "signup")]
Signup, Signup,
[EnumMember(Value = "upgrade-plan")] [EnumMember(Value = "upgrade-plan")]

View File

@ -256,7 +256,19 @@ public class ReferenceEvent
public string? PlanUpgradePath { get; set; } public string? PlanUpgradePath { get; set; }
/// <summary> /// <summary>
/// Used for the sign up event to determine if the user has opted in to marketing emails. /// Used for the <see cref="ReferenceEventType.Signup"/> event to determine if the user has opted in to marketing emails.
/// </summary> /// </summary>
public bool? ReceiveMarketingEmails { get; set; } public bool? ReceiveMarketingEmails { get; set; }
/// <summary>
/// Used for the <see cref="ReferenceEventType.SignupEmailClicked"/> event to indicate if the user
/// landed on the registration finish screen with a valid or invalid email verification token.
/// </summary>
public bool? EmailVerificationTokenValid { get; set; }
/// <summary>
/// Used for the <see cref="ReferenceEventType.SignupEmailClicked"/> event to indicate if the user
/// landed on the registration finish screen after re-clicking an already used link.
/// </summary>
public bool? UserAlreadyExists { get; set; }
} }

View File

@ -41,6 +41,7 @@ public class AccountsController : Controller
private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
public AccountsController( public AccountsController(
ICurrentContext currentContext, ICurrentContext currentContext,
@ -52,7 +53,8 @@ public class AccountsController : Controller
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand, IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand, ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
IFeatureService featureService IFeatureService featureService,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory
) )
{ {
_currentContext = currentContext; _currentContext = currentContext;
@ -65,6 +67,7 @@ public class AccountsController : Controller
_sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand; _sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand;
_referenceEventService = referenceEventService; _referenceEventService = referenceEventService;
_featureService = featureService; _featureService = featureService;
_registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory;
} }
[HttpPost("register")] [HttpPost("register")]
@ -90,7 +93,7 @@ public class AccountsController : Controller
Type = ReferenceEventType.SignupEmailSubmit, Type = ReferenceEventType.SignupEmailSubmit,
ClientId = _currentContext.ClientId, ClientId = _currentContext.ClientId,
ClientVersion = _currentContext.ClientVersion, ClientVersion = _currentContext.ClientVersion,
Source = ReferenceEventSource.RegistrationStart Source = ReferenceEventSource.Registration
}; };
await _referenceEventService.RaiseEventAsync(refEvent); await _referenceEventService.RaiseEventAsync(refEvent);
@ -102,6 +105,39 @@ public class AccountsController : Controller
return NoContent(); return NoContent();
} }
[RequireFeature(FeatureFlagKeys.EmailVerification)]
[HttpPost("register/verification-email-clicked")]
public async Task<IActionResult> PostRegisterVerificationEmailClicked([FromBody] RegisterVerificationEmailClickedRequestModel model)
{
var tokenValid = RegistrationEmailVerificationTokenable.ValidateToken(_registrationEmailVerificationTokenDataFactory, model.EmailVerificationToken, model.Email);
// Check to see if the user already exists - this is just to catch the unlikely but possible case
// where a user finishes registration and then clicks the email verification link again.
var user = await _userRepository.GetByEmailAsync(model.Email);
var userExists = user != null;
var refEvent = new ReferenceEvent
{
Type = ReferenceEventType.SignupEmailClicked,
ClientId = _currentContext.ClientId,
ClientVersion = _currentContext.ClientVersion,
Source = ReferenceEventSource.Registration,
EmailVerificationTokenValid = tokenValid,
UserAlreadyExists = userExists
};
await _referenceEventService.RaiseEventAsync(refEvent);
if (!tokenValid || userExists)
{
throw new BadRequestException("Expired link. Please restart registration or try logging in. You may already have an account");
}
return Ok();
}
[RequireFeature(FeatureFlagKeys.EmailVerification)] [RequireFeature(FeatureFlagKeys.EmailVerification)]
[HttpPost("register/finish")] [HttpPost("register/finish")]
public async Task<RegisterResponseModel> PostRegisterFinish([FromBody] RegisterFinishRequestModel model) public async Task<RegisterResponseModel> PostRegisterFinish([FromBody] RegisterFinishRequestModel model)

View File

@ -268,6 +268,43 @@ public class AccountsControllerTests : IClassFixture<IdentityApplicationFactory>
Assert.Equal(kdfParallelism, user.KdfParallelism); Assert.Equal(kdfParallelism, user.KdfParallelism);
} }
[Theory, BitAutoData]
public async Task PostRegisterVerificationEmailClicked_Success(
[Required, StringLength(20)] string name,
string emailVerificationToken)
{
// Arrange
// Localize substitutions to this test.
var localFactory = new IdentityApplicationFactory();
var email = $"test+register+{name}@email.com";
var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable(email);
localFactory.SubstituteService<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>(emailVerificationTokenDataProtectorFactory =>
{
emailVerificationTokenDataProtectorFactory.TryUnprotect(Arg.Is(emailVerificationToken), out Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(callInfo =>
{
callInfo[1] = registrationEmailVerificationTokenable;
return true;
});
});
var requestModel = new RegisterVerificationEmailClickedRequestModel
{
Email = email,
EmailVerificationToken = emailVerificationToken
};
// Act
var httpContext = await localFactory.PostRegisterVerificationEmailClicked(requestModel);
var body = await httpContext.ReadBodyAsStringAsync();
// Assert
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}
private async Task<User> CreateUserAsync(string email, string name, IdentityApplicationFactory factory = null) private async Task<User> CreateUserAsync(string email, string name, IdentityApplicationFactory factory = null)
{ {
var factoryToUse = factory ?? _factory; var factoryToUse = factory ?? _factory;

View File

@ -41,6 +41,8 @@ public class AccountsControllerTests : IDisposable
private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
public AccountsControllerTests() public AccountsControllerTests()
{ {
@ -54,6 +56,8 @@ public class AccountsControllerTests : IDisposable
_sendVerificationEmailForRegistrationCommand = Substitute.For<ISendVerificationEmailForRegistrationCommand>(); _sendVerificationEmailForRegistrationCommand = Substitute.For<ISendVerificationEmailForRegistrationCommand>();
_referenceEventService = Substitute.For<IReferenceEventService>(); _referenceEventService = Substitute.For<IReferenceEventService>();
_featureService = Substitute.For<IFeatureService>(); _featureService = Substitute.For<IFeatureService>();
_registrationEmailVerificationTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>();
_sut = new AccountsController( _sut = new AccountsController(
_currentContext, _currentContext,
_logger, _logger,
@ -64,7 +68,8 @@ public class AccountsControllerTests : IDisposable
_getWebAuthnLoginCredentialAssertionOptionsCommand, _getWebAuthnLoginCredentialAssertionOptionsCommand,
_sendVerificationEmailForRegistrationCommand, _sendVerificationEmailForRegistrationCommand,
_referenceEventService, _referenceEventService,
_featureService _featureService,
_registrationEmailVerificationTokenDataFactory
); );
} }
@ -380,4 +385,105 @@ public class AccountsControllerTests : IDisposable
Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage); Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage);
} }
[Theory, BitAutoData]
public async Task PostRegisterVerificationEmailClicked_WhenTokenIsValid_ShouldReturnOk(string email, string emailVerificationToken)
{
// Arrange
var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable(email);
_registrationEmailVerificationTokenDataFactory
.TryUnprotect(emailVerificationToken, out Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(callInfo =>
{
callInfo[1] = registrationEmailVerificationTokenable;
return true;
});
_userRepository.GetByEmailAsync(email).ReturnsNull(); // no existing user
var requestModel = new RegisterVerificationEmailClickedRequestModel
{
Email = email,
EmailVerificationToken = emailVerificationToken
};
// Act
var result = await _sut.PostRegisterVerificationEmailClicked(requestModel);
// Assert
var okResult = Assert.IsType<OkResult>(result);
Assert.Equal(200, okResult.StatusCode);
await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is<ReferenceEvent>(e =>
e.Type == ReferenceEventType.SignupEmailClicked
&& e.EmailVerificationTokenValid == true
&& e.UserAlreadyExists == false
));
}
[Theory, BitAutoData]
public async Task PostRegisterVerificationEmailClicked_WhenTokenIsInvalid_ShouldReturnBadRequest(string email, string emailVerificationToken)
{
// Arrange
var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable("wrongEmail");
_registrationEmailVerificationTokenDataFactory
.TryUnprotect(emailVerificationToken, out Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(callInfo =>
{
callInfo[1] = registrationEmailVerificationTokenable;
return true;
});
_userRepository.GetByEmailAsync(email).ReturnsNull(); // no existing user
var requestModel = new RegisterVerificationEmailClickedRequestModel
{
Email = email,
EmailVerificationToken = emailVerificationToken
};
// Act & assert
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostRegisterVerificationEmailClicked(requestModel));
await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is<ReferenceEvent>(e =>
e.Type == ReferenceEventType.SignupEmailClicked
&& e.EmailVerificationTokenValid == false
&& e.UserAlreadyExists == false
));
}
[Theory, BitAutoData]
public async Task PostRegisterVerificationEmailClicked_WhenTokenIsValidButExistingUser_ShouldReturnBadRequest(string email, string emailVerificationToken, User existingUser)
{
// Arrange
var registrationEmailVerificationTokenable = new RegistrationEmailVerificationTokenable(email);
_registrationEmailVerificationTokenDataFactory
.TryUnprotect(emailVerificationToken, out Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(callInfo =>
{
callInfo[1] = registrationEmailVerificationTokenable;
return true;
});
_userRepository.GetByEmailAsync(email).Returns(existingUser);
var requestModel = new RegisterVerificationEmailClickedRequestModel
{
Email = email,
EmailVerificationToken = emailVerificationToken
};
// Act & assert
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostRegisterVerificationEmailClicked(requestModel));
await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is<ReferenceEvent>(e =>
e.Type == ReferenceEventType.SignupEmailClicked
&& e.EmailVerificationTokenValid == true
&& e.UserAlreadyExists == true
));
}
} }

View File

@ -29,6 +29,11 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
return await Server.PostAsync("/accounts/register/finish", JsonContent.Create(model)); return await Server.PostAsync("/accounts/register/finish", JsonContent.Create(model));
} }
public async Task<HttpContext> PostRegisterVerificationEmailClicked(RegisterVerificationEmailClickedRequestModel model)
{
return await Server.PostAsync("/accounts/register/verification-email-clicked", JsonContent.Create(model));
}
public async Task<(string Token, string RefreshToken)> TokenFromPasswordAsync(string username, public async Task<(string Token, string RefreshToken)> TokenFromPasswordAsync(string username,
string password, string password,
string deviceIdentifier = DefaultDeviceIdentifier, string deviceIdentifier = DefaultDeviceIdentifier,