Skip to content

Commit 05681ca

Browse files
fix(change-email): Updated with feedback from peers
1 parent 49092a3 commit 05681ca

10 files changed

Lines changed: 239 additions & 173 deletions

File tree

src/Api/AdminConsole/Controllers/OrganizationUsersController.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
using Bit.Core.AdminConsole.Utilities.v2;
2626
using Bit.Core.Auth.Enums;
2727
using Bit.Core.Auth.Repositories;
28-
using Bit.Core.Auth.UserFeatures.UserEmail.Interfaces;
2928
using Bit.Core.Billing.Pricing;
3029
using Bit.Core.Context;
3130
using Bit.Core.Entities;
@@ -85,7 +84,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
8584
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
8685
private readonly AccountRecoveryV2.IAdminRecoverAccountCommand _adminRecoverAccountCommandV2;
8786
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
88-
private readonly IChangeEmailForPasswordlessUserCommand _changeEmailForPasswordlessUserCommand;
87+
private readonly IChangeEmailForPasswordlessOrgUserCommand _changeEmailForPasswordlessOrgUserCommand;
8988

9089
public OrganizationUsersController(IOrganizationRepository organizationRepository,
9190
IOrganizationUserRepository organizationUserRepository,
@@ -119,7 +118,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
119118
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
120119
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext,
121120
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand,
122-
IChangeEmailForPasswordlessUserCommand changeEmailForPasswordlessUserCommand)
121+
IChangeEmailForPasswordlessOrgUserCommand changeEmailForPasswordlessOrgUserCommand)
123122
{
124123
_organizationRepository = organizationRepository;
125124
_organizationUserRepository = organizationUserRepository;
@@ -153,7 +152,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
153152
_adminRecoverAccountCommand = adminRecoverAccountCommand;
154153
_adminRecoverAccountCommandV2 = adminRecoverAccountCommandV2;
155154
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
156-
_changeEmailForPasswordlessUserCommand = changeEmailForPasswordlessUserCommand;
155+
_changeEmailForPasswordlessOrgUserCommand = changeEmailForPasswordlessOrgUserCommand;
157156
}
158157

159158
[HttpGet("{id}")]
@@ -574,7 +573,7 @@ public async Task<IResult> ChangeEmailForPasswordlessUser(
574573
return TypedResults.NotFound();
575574
}
576575

577-
await _changeEmailForPasswordlessUserCommand.ChangeOrganizationUserEmailAsync(orgId, targetOrganizationUser, model.NewEmail);
576+
await _changeEmailForPasswordlessOrgUserCommand.ChangeOrganizationUserEmailAsync(orgId, targetOrganizationUser, model.NewEmail);
578577
return TypedResults.NoContent();
579578
}
580579

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
2+
using Bit.Core.Auth.UserFeatures.UserEmail.Interfaces;
3+
using Bit.Core.Entities;
4+
using Bit.Core.Exceptions;
5+
using Bit.Core.Repositories;
6+
using Bit.Core.Utilities;
7+
8+
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
9+
10+
public class ChangeEmailForPasswordlessOrgUserCommand : IChangeEmailForPasswordlessOrgUserCommand
11+
{
12+
private readonly IUserRepository _userRepository;
13+
private readonly IOrganizationDomainRepository _organizationDomainRepository;
14+
private readonly IChangeEmailCommand _changeEmailCommand;
15+
16+
public ChangeEmailForPasswordlessOrgUserCommand(
17+
IUserRepository userRepository,
18+
IOrganizationDomainRepository organizationDomainRepository,
19+
IChangeEmailCommand changeEmailCommand)
20+
{
21+
_userRepository = userRepository;
22+
_organizationDomainRepository = organizationDomainRepository;
23+
_changeEmailCommand = changeEmailCommand;
24+
}
25+
26+
public async Task ChangeOrganizationUserEmailAsync(Guid organizationId, OrganizationUser organizationUser, string newEmail)
27+
{
28+
var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value)
29+
?? throw new NotFoundException();
30+
31+
if (user.HasMasterPassword())
32+
{
33+
throw new BadRequestException("User has a master password.");
34+
}
35+
36+
var newDomain = CoreHelpers.GetEmailDomain(newEmail);
37+
var claimedDomain = await _organizationDomainRepository
38+
.GetDomainByOrgIdAndDomainNameAsync(organizationId, newDomain!);
39+
40+
if (claimedDomain?.VerifiedDate == null)
41+
{
42+
throw new BadRequestException("The email domain is not claimed by the organization.");
43+
}
44+
45+
await _changeEmailCommand.ChangeEmailAsync(user, newEmail, logOutUser: false);
46+
}
47+
}

src/Core/Auth/UserFeatures/UserEmail/Interfaces/IChangeEmailForPasswordlessUserCommand.cs renamed to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IChangeEmailForPasswordlessOrgUserCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
using Bit.Core.Entities;
22

3-
namespace Bit.Core.Auth.UserFeatures.UserEmail.Interfaces;
3+
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
44

5-
public interface IChangeEmailForPasswordlessUserCommand
5+
public interface IChangeEmailForPasswordlessOrgUserCommand
66
{
77
Task ChangeOrganizationUserEmailAsync(Guid organizationId, OrganizationUser organizationUser, string newEmail);
88
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using Bit.Core.Auth.UserFeatures.UserEmail.Interfaces;
2+
using Bit.Core.Billing.Services;
3+
using Bit.Core.Entities;
4+
using Bit.Core.Enums;
5+
using Bit.Core.Exceptions;
6+
using Bit.Core.Platform.Push;
7+
using Bit.Core.Repositories;
8+
9+
namespace Bit.Core.Auth.UserFeatures.UserEmail;
10+
11+
public class ChangeEmailCommand : IChangeEmailCommand
12+
{
13+
private readonly IUserRepository _userRepository;
14+
private readonly IStripeSyncService _stripeSyncService;
15+
private readonly IPushNotificationService _pushNotificationService;
16+
17+
public ChangeEmailCommand(
18+
IUserRepository userRepository,
19+
IStripeSyncService stripeSyncService,
20+
IPushNotificationService pushNotificationService)
21+
{
22+
_userRepository = userRepository;
23+
_stripeSyncService = stripeSyncService;
24+
_pushNotificationService = pushNotificationService;
25+
}
26+
27+
public async Task ChangeEmailAsync(User user, string newEmail, bool logOutUser = true)
28+
{
29+
// Querying by email exposes a limited account-enumeration vector: a distinct error response
30+
// ("Email already in use.") vs. success lets a caller infer whether a Bitwarden account exists
31+
// at a given address. Callers are responsible for enforcing access controls before reaching this
32+
// point that bound who can probe and which addresses are reachable.
33+
var existingUser = await _userRepository.GetByEmailAsync(newEmail);
34+
if (existingUser != null && existingUser.Id != user.Id)
35+
{
36+
throw new BadRequestException("Email already in use.");
37+
}
38+
39+
var previousEmail = user.Email;
40+
var now = DateTime.UtcNow;
41+
user.Email = newEmail;
42+
user.EmailVerified = true;
43+
user.RevisionDate = user.AccountRevisionDate = now;
44+
user.LastEmailChangeDate = now;
45+
await _userRepository.ReplaceAsync(user);
46+
47+
if (user.Gateway == GatewayType.Stripe && user.GatewayCustomerId != null)
48+
{
49+
try
50+
{
51+
await _stripeSyncService.UpdateCustomerEmailAddressAsync(
52+
user.GatewayCustomerId,
53+
user.BillingEmailAddress()!);
54+
}
55+
catch
56+
{
57+
user.Email = previousEmail;
58+
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
59+
await _userRepository.ReplaceAsync(user);
60+
throw;
61+
}
62+
}
63+
64+
if (logOutUser)
65+
{
66+
await _pushNotificationService.PushLogOutAsync(user.Id);
67+
}
68+
}
69+
}

src/Core/Auth/UserFeatures/UserEmail/ChangeEmailForPasswordlessUserCommand.cs

Lines changed: 0 additions & 90 deletions
This file was deleted.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using Bit.Core.Entities;
2+
3+
namespace Bit.Core.Auth.UserFeatures.UserEmail.Interfaces;
4+
5+
public interface IChangeEmailCommand
6+
{
7+
Task ChangeEmailAsync(User user, string newEmail, bool logOutUser = true);
8+
}

src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,6 @@ private static void AddSsoQueries(this IServiceCollection services)
9191

9292
private static void AddUserEmailCommands(this IServiceCollection services)
9393
{
94-
services.AddScoped<IChangeEmailForPasswordlessUserCommand, ChangeEmailForPasswordlessUserCommand>();
94+
services.AddScoped<IChangeEmailCommand, ChangeEmailCommand>();
9595
}
9696
}

src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ private static void AddOrganizationUserCommands(this IServiceCollection services
163163
services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserValidator, V2_RevokeUsersCommand.RevokeOrganizationUsersValidator>();
164164

165165
services.AddScoped<ISelfRevokeOrganizationUserCommand, SelfRevokeOrganizationUserCommand>();
166+
services.AddScoped<IChangeEmailForPasswordlessOrgUserCommand, ChangeEmailForPasswordlessOrgUserCommand>();
166167
}
167168

168169
private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)

0 commit comments

Comments
 (0)