1
0
mirror of https://github.com/bitwarden/server synced 2026-01-03 09:03:44 +00:00

Merge branch 'master' into flexible-collections/deprecate-custom-collection-perm

# Conflicts:
#	src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
This commit is contained in:
Rui Tome
2023-11-02 15:20:58 +00:00
31 changed files with 2172 additions and 301 deletions

View File

@@ -14,6 +14,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.Data.Organizations.Policies;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
@@ -36,6 +37,7 @@ public class OrganizationUsersController : Controller
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IFeatureService _featureService;
private readonly IAuthorizationService _authorizationService;
@@ -53,6 +55,7 @@ public class OrganizationUsersController : Controller
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IAcceptOrgUserCommand acceptOrgUserCommand,
IFeatureService featureService,
IAuthorizationService authorizationService)
{
@@ -67,6 +70,7 @@ public class OrganizationUsersController : Controller
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_acceptOrgUserCommand = acceptOrgUserCommand;
_featureService = featureService;
_authorizationService = authorizationService;
}
@@ -212,7 +216,7 @@ public class OrganizationUsersController : Controller
}
await _organizationService.InitPendingOrganization(user.Id, orgId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName);
await _organizationService.AcceptUserAsync(organizationUserId, user, model.Token, _userService);
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
await _organizationService.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id, _userService);
}
@@ -234,7 +238,7 @@ public class OrganizationUsersController : Controller
throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided.");
}
await _organizationService.AcceptUserAsync(organizationUserId, user, model.Token, _userService);
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
if (useMasterPasswordPolicy)
{
@@ -345,7 +349,7 @@ public class OrganizationUsersController : Controller
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, user.Id);
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
await _organizationService.AcceptUserAsync(orgId, user, _userService);
await _acceptOrgUserCommand.AcceptOrgUserByOrgIdAsync(orgId, user, _userService);
}
}

View File

@@ -1,5 +1,6 @@
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -7,6 +8,7 @@ using Bit.Core.Models.Api.Response;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
@@ -26,6 +28,7 @@ public class PoliciesController : Controller
private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
private readonly IDataProtector _organizationServiceDataProtector;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
public PoliciesController(
IPolicyRepository policyRepository,
@@ -35,7 +38,8 @@ public class PoliciesController : Controller
IUserService userService,
ICurrentContext currentContext,
GlobalSettings globalSettings,
IDataProtectionProvider dataProtectionProvider)
IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
{
_policyRepository = policyRepository;
_policyService = policyService;
@@ -46,6 +50,8 @@ public class PoliciesController : Controller
_globalSettings = globalSettings;
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
"OrganizationServiceDataProtector");
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
}
[HttpGet("{type}")]
@@ -81,41 +87,46 @@ public class PoliciesController : Controller
[AllowAnonymous]
[HttpGet("token")]
public async Task<ListResponseModel<PolicyResponseModel>> GetByToken(string orgId, [FromQuery] string email,
[FromQuery] string token, [FromQuery] string organizationUserId)
public async Task<ListResponseModel<PolicyResponseModel>> GetByToken(Guid orgId, [FromQuery] string email,
[FromQuery] string token, [FromQuery] Guid organizationUserId)
{
var orgUserId = new Guid(organizationUserId);
var tokenValid = CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token,
email, orgUserId, _globalSettings);
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, token, organizationUserId, email);
var tokenValid = newTokenValid || CoreHelpers.UserInviteTokenIsValid(
_organizationServiceDataProtector, token, email, organizationUserId, _globalSettings
);
if (!tokenValid)
{
throw new NotFoundException();
}
var orgIdGuid = new Guid(orgId);
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId);
if (orgUser == null || orgUser.OrganizationId != orgIdGuid)
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (orgUser == null || orgUser.OrganizationId != orgId)
{
throw new NotFoundException();
}
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgId);
var responses = policies.Where(p => p.Enabled).Select(p => new PolicyResponseModel(p));
return new ListResponseModel<PolicyResponseModel>(responses);
}
// TODO: PM-4097 - remove GetByInvitedUser once all clients are updated to use the GetMasterPasswordPolicy endpoint below
[Obsolete("Deprecated API", false)]
[AllowAnonymous]
[HttpGet("invited-user")]
public async Task<ListResponseModel<PolicyResponseModel>> GetByInvitedUser(string orgId, [FromQuery] string userId)
public async Task<ListResponseModel<PolicyResponseModel>> GetByInvitedUser(Guid orgId, [FromQuery] Guid userId)
{
var user = await _userService.GetUserByIdAsync(new Guid(userId));
var user = await _userService.GetUserByIdAsync(userId);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var orgIdGuid = new Guid(orgId);
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id);
var orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgIdGuid);
var orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
if (orgUser == null)
{
throw new NotFoundException();
@@ -125,11 +136,33 @@ public class PoliciesController : Controller
throw new UnauthorizedAccessException();
}
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgId);
var responses = policies.Where(p => p.Enabled).Select(p => new PolicyResponseModel(p));
return new ListResponseModel<PolicyResponseModel>(responses);
}
[HttpGet("master-password")]
public async Task<PolicyResponseModel> GetMasterPasswordPolicy(Guid orgId)
{
var userId = _userService.GetProperUserId(User).Value;
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId);
if (orgUser == null)
{
throw new NotFoundException();
}
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.MasterPassword);
if (policy == null || !policy.Enabled)
{
throw new NotFoundException();
}
return new PolicyResponseModel(policy);
}
[HttpPut("{type}")]
public async Task<PolicyResponseModel> Put(string orgId, int type, [FromBody] PolicyRequestModel model)
{

View File

@@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -15,8 +17,7 @@ public class SetPasswordRequestModel : IValidatableObject
public string Key { get; set; }
[StringLength(50)]
public string MasterPasswordHint { get; set; }
[Required]
public KeysRequestModel Keys { get; set; }
public KeysRequestModel? Keys { get; set; }
[Required]
public KdfType Kdf { get; set; }
[Required]
@@ -33,7 +34,7 @@ public class SetPasswordRequestModel : IValidatableObject
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys.ToUser(existingUser);
Keys?.ToUser(existingUser);
return existingUser;
}

View File

@@ -10,6 +10,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Api.Response.Accounts;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Auth.Utilities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -47,6 +48,8 @@ public class AccountsController : Controller
private readonly ISendService _sendService;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IPolicyService _policyService;
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
public AccountsController(
GlobalSettings globalSettings,
@@ -61,7 +64,9 @@ public class AccountsController : Controller
ISendRepository sendRepository,
ISendService sendService,
ICaptchaValidationService captchaValidationService,
IPolicyService policyService)
IPolicyService policyService,
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand
)
{
_cipherRepository = cipherRepository;
_folderRepository = folderRepository;
@@ -76,6 +81,7 @@ public class AccountsController : Controller
_sendService = sendService;
_captchaValidationService = captchaValidationService;
_policyService = policyService;
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
}
#region DEPRECATED (Moved to Identity Service)
@@ -253,8 +259,12 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
var result = await _userService.SetPasswordAsync(model.ToUser(user), model.MasterPasswordHash, model.Key,
var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
model.ToUser(user),
model.MasterPasswordHash,
model.Key,
model.OrgIdentifier);
if (result.Succeeded)
{
return;
@@ -456,8 +466,13 @@ public class AccountsController : Controller
var providerUserOrganizationDetails =
await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id,
ProviderUserStatusType.Confirmed);
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
providerUserOrganizationDetails, twoFactorEnabled,
hasPremiumFromOrg);
return response;
}

View File

@@ -71,6 +71,7 @@ public class SyncController : Controller
await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id,
ProviderUserStatusType.Confirmed);
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
var folders = await _folderRepository.GetManyByUserIdAsync(user.Id);
var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, hasEnabledOrgs);
var sends = await _sendRepository.GetManyByUserIdAsync(user.Id);

View File

@@ -0,0 +1,8 @@
using Bit.Core.Entities;
namespace Bit.Core.Auth.Models.Business.Tokenables;
public interface IOrgUserInviteTokenableFactory
{
OrgUserInviteTokenable CreateToken(OrganizationUser orgUser);
}

View File

@@ -0,0 +1,84 @@
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Tokens;
namespace Bit.Core.Auth.Models.Business.Tokenables;
public class OrgUserInviteTokenable : ExpiringTokenable
{
// TODO: PM-4317 - Ideally this would be internal and only visible to the test project.
// but configuring that is out of scope for these changes.
public static TimeSpan GetTokenLifetime() => TimeSpan.FromDays(5);
public const string ClearTextPrefix = "BwOrgUserInviteToken_";
// Backwards compatibility Note:
// Previously, tokens were manually created in the OrganizationService using a data protector
// initialized with purpose: "OrganizationServiceDataProtector"
// So, we must continue to use the existing purpose to be able to decrypt tokens
// in emailed invites that have not yet been accepted.
public const string DataProtectorPurpose = "OrganizationServiceDataProtector";
public const string TokenIdentifier = "OrgUserInviteToken";
public string Identifier { get; set; } = TokenIdentifier;
public Guid OrgUserId { get; set; }
public string OrgUserEmail { get; set; }
[JsonConstructor]
public OrgUserInviteTokenable()
{
ExpirationDate = DateTime.UtcNow.Add(GetTokenLifetime());
}
public OrgUserInviteTokenable(OrganizationUser orgUser) : this()
{
OrgUserId = orgUser?.Id ?? default;
OrgUserEmail = orgUser?.Email;
}
public bool TokenIsValid(OrganizationUser orgUser)
{
if (OrgUserId == default || OrgUserEmail == default || orgUser == null)
{
return false;
}
return OrgUserId == orgUser.Id &&
OrgUserEmail.Equals(orgUser.Email, StringComparison.InvariantCultureIgnoreCase);
}
public bool TokenIsValid(Guid orgUserId, string orgUserEmail)
{
if (OrgUserId == default || OrgUserEmail == default || orgUserId == default || orgUserEmail == default)
{
return false;
}
return OrgUserId == orgUserId &&
OrgUserEmail.Equals(orgUserEmail, StringComparison.InvariantCultureIgnoreCase);
}
// Validates deserialized
protected override bool TokenIsValid() =>
Identifier == TokenIdentifier && OrgUserId != default && !string.IsNullOrWhiteSpace(OrgUserEmail);
public static bool ValidateOrgUserInviteStringToken(
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
string orgUserInviteToken, OrganizationUser orgUser)
{
return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken)
&& decryptedToken.Valid
&& decryptedToken.TokenIsValid(orgUser);
}
public static bool ValidateOrgUserInviteStringToken(
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
string orgUserInviteToken, Guid orgUserId, string orgUserEmail)
{
return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken)
&& decryptedToken.Valid
&& decryptedToken.TokenIsValid(orgUserId, orgUserEmail);
}
}

View File

@@ -0,0 +1,23 @@
using Bit.Core.Entities;
using Bit.Core.Settings;
namespace Bit.Core.Auth.Models.Business.Tokenables;
public class OrgUserInviteTokenableFactory : IOrgUserInviteTokenableFactory
{
private readonly IGlobalSettings _globalSettings;
public OrgUserInviteTokenableFactory(IGlobalSettings globalSettings)
{
_globalSettings = globalSettings;
}
public OrgUserInviteTokenable CreateToken(OrganizationUser orgUser)
{
var token = new OrgUserInviteTokenable(orgUser)
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromHours(_globalSettings.OrganizationInviteExpirationHours))
};
return token;
}
}

View File

@@ -16,12 +16,9 @@ public class SsoEmail2faSessionTokenable : ExpiringTokenable
public const string DataProtectorPurpose = "SsoEmail2faSessionTokenDataProtector";
public const string TokenIdentifier = "SsoEmail2faSessionToken";
public string Identifier { get; set; } = TokenIdentifier;
public Guid Id { get; set; }
public string Email { get; set; }
[JsonConstructor]
public SsoEmail2faSessionTokenable()
{
@@ -33,14 +30,12 @@ public class SsoEmail2faSessionTokenable : ExpiringTokenable
Id = user?.Id ?? default;
Email = user?.Email;
}
public bool TokenIsValid(User user)
{
if (Id == default || Email == default || user == null)
{
return false;
}
return Id == user.Id &&
Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase);
}

View File

@@ -0,0 +1,19 @@
using Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
/// <summary>
/// <para>Manages the setting of the initial master password for a <see cref="User"/> in an organization.</para>
/// <para>This class is primarily invoked in two scenarios:</para>
/// <para>1) In organizations configured with Single Sign-On (SSO) and master password decryption:
/// just in time (JIT) provisioned users logging in via SSO are required to set a master password.</para>
/// <para>2) In organizations configured with SSO and trusted devices decryption:
/// Users who are upgraded to have admin account recovery permissions must set a master password
/// to ensure their ability to reset other users' accounts.</para>
/// </summary>
public interface ISetInitialMasterPasswordCommand
{
public Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
string orgSsoIdentifier);
}

View File

@@ -0,0 +1,103 @@
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword;
public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand
{
private readonly ILogger<SetInitialMasterPasswordCommand> _logger;
private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly IEventService _eventService;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
public SetInitialMasterPasswordCommand(
ILogger<SetInitialMasterPasswordCommand> logger,
IdentityErrorDescriber identityErrorDescriber,
IUserService userService,
IUserRepository userRepository,
IEventService eventService,
IAcceptOrgUserCommand acceptOrgUserCommand,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository)
{
_logger = logger;
_identityErrorDescriber = identityErrorDescriber;
_userService = userService;
_userRepository = userRepository;
_eventService = eventService;
_acceptOrgUserCommand = acceptOrgUserCommand;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
}
public async Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
string orgSsoIdentifier)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (!string.IsNullOrWhiteSpace(user.MasterPassword))
{
_logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id);
return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword());
}
var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false);
if (!result.Succeeded)
{
return result;
}
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
user.Key = key;
await _userRepository.ReplaceAsync(user);
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
if (string.IsNullOrWhiteSpace(orgSsoIdentifier))
{
throw new BadRequestException("Organization SSO Identifier required.");
}
var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier);
if (org == null)
{
throw new BadRequestException("Organization invalid.");
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
// TDE users who go from a user without admin acct recovery permission to having it will be
// required to set a MP for the first time and we don't want to re-execute the accept logic
// as they are already confirmed.
// TLDR: only accept post SSO user if they are invited
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService);
}
return IdentityResult.Success;
}
}

View File

@@ -0,0 +1,24 @@

using Bit.Core.Auth.UserFeatures.UserMasterPassword;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.UserFeatures;
public static class UserServiceCollectionExtensions
{
public static void AddUserServices(this IServiceCollection services, IGlobalSettings globalSettings)
{
services.AddScoped<IUserService, UserService>();
services.AddUserPasswordCommands();
}
private static void AddUserPasswordCommands(this IServiceCollection services)
{
services.AddScoped<ISetInitialMasterPasswordCommand, SetInitialMasterPasswordCommand>();
}
}

View File

@@ -21,6 +21,8 @@ using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterpri
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.SelfHosted;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.OrganizationFeatures.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
@@ -132,6 +134,7 @@ public static class OrganizationServiceCollectionExtensions
private static void AddOrganizationUserCommandsQueries(this IServiceCollection services)
{
services.AddScoped<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>();
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
}
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of

View File

@@ -0,0 +1,220 @@
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
namespace Bit.Core.OrganizationFeatures.OrganizationUsers;
public class AcceptOrgUserCommand : IAcceptOrgUserCommand
{
private readonly IDataProtector _dataProtector;
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyService _policyService;
private readonly IMailService _mailService;
private readonly IUserRepository _userRepository;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
public AcceptOrgUserCommand(
IDataProtectionProvider dataProtectionProvider,
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyService policyService,
IMailService mailService,
IUserRepository userRepository,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
{
// TODO: remove data protector when old token validation removed
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
_globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_policyService = policyService;
_mailService = mailService;
_userRepository = userRepository;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
}
public async Task<OrganizationUser> AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken,
IUserService userService)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (orgUser == null)
{
throw new BadRequestException("User invalid.");
}
// Tokens will have been created in two ways in the OrganizationService invite methods:
// 1. New way - via OrgUserInviteTokenable
// 2. Old way - via manual process using data protector initialized with purpose: "OrganizationServiceDataProtector"
// For backwards compatibility, must check validity of both types of tokens and accept if either is valid
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, emailToken, orgUser);
var tokenValid = newTokenValid ||
CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id,
_globalSettings);
if (!tokenValid)
{
throw new BadRequestException("Invalid token.");
}
var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync(
orgUser.OrganizationId, user.Email, true);
if (existingOrgUserCount > 0)
{
if (orgUser.Status == OrganizationUserStatusType.Accepted)
{
throw new BadRequestException("Invitation already accepted. You will receive an email when your organization membership is confirmed.");
}
throw new BadRequestException("You are already part of this organization.");
}
if (string.IsNullOrWhiteSpace(orgUser.Email) ||
!orgUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("User email does not match invite.");
}
var organizationUser = await AcceptOrgUserAsync(orgUser, user, userService);
// Verify user email if they accept org invite via email link
if (user.EmailVerified == false)
{
user.EmailVerified = true;
await _userRepository.ReplaceAsync(user);
}
return organizationUser;
}
private bool ValidateOrgUserInviteToken(string orgUserInviteToken, OrganizationUser orgUser)
{
return _orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken)
&& decryptedToken.Valid
&& decryptedToken.TokenIsValid(orgUser);
}
public async Task<OrganizationUser> AcceptOrgUserByOrgSsoIdAsync(string orgSsoIdentifier, User user, IUserService userService)
{
var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier);
if (org == null)
{
throw new BadRequestException("Organization invalid.");
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
return await AcceptOrgUserAsync(orgUser, user, userService);
}
public async Task<OrganizationUser> AcceptOrgUserByOrgIdAsync(Guid organizationId, User user, IUserService userService)
{
var org = await _organizationRepository.GetByIdAsync(organizationId);
if (org == null)
{
throw new BadRequestException("Organization invalid.");
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
return await AcceptOrgUserAsync(orgUser, user, userService);
}
public async Task<OrganizationUser> AcceptOrgUserAsync(OrganizationUser orgUser, User user,
IUserService userService)
{
if (orgUser.Status == OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Your organization access has been revoked.");
}
if (orgUser.Status != OrganizationUserStatusType.Invited)
{
throw new BadRequestException("Already accepted.");
}
if (orgUser.Type == OrganizationUserType.Owner || orgUser.Type == OrganizationUserType.Admin)
{
var org = await _organizationRepository.GetByIdAsync(orgUser.OrganizationId);
if (org.PlanType == PlanType.Free)
{
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(
user.Id);
if (adminCount > 0)
{
throw new BadRequestException("You can only be an admin of one free organization.");
}
}
}
// Enforce Single Organization Policy of organization user is trying to join
var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(user.Id);
var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId);
var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id,
PolicyType.SingleOrg, OrganizationUserStatusType.Invited);
if (hasOtherOrgs && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
{
throw new BadRequestException("You may not join this organization until you leave or remove all other organizations.");
}
// Enforce Single Organization Policy of other organizations user is a member of
var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id,
PolicyType.SingleOrg);
if (anySingleOrgPolicies)
{
throw new BadRequestException("You cannot join this organization because you are a member of another organization which forbids it");
}
// Enforce Two Factor Authentication Policy of organization user is trying to join
if (!await userService.TwoFactorIsEnabledAsync(user))
{
var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id,
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited);
if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
{
throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account.");
}
}
orgUser.Status = OrganizationUserStatusType.Accepted;
orgUser.UserId = user.Id;
orgUser.Email = null;
await _organizationUserRepository.ReplaceAsync(orgUser);
var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin);
var adminEmails = admins.Select(a => a.Email).Distinct().ToList();
if (adminEmails.Count > 0)
{
var organization = await _organizationRepository.GetByIdAsync(orgUser.OrganizationId);
await _mailService.SendOrganizationAcceptedEmailAsync(organization, user.Email, adminEmails);
}
return orgUser;
}
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.Entities;
using Bit.Core.Services;
namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IAcceptOrgUserCommand
{
/// <summary>
/// Moves an OrganizationUser into the Accepted status and marks their email as verified.
/// This method is used where the user has clicked the invitation link sent by email.
/// </summary>
/// <param name="emailToken">The token embedded in the email invitation link</param>
/// <returns>The accepted OrganizationUser.</returns>
Task<OrganizationUser> AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, IUserService userService);
Task<OrganizationUser> AcceptOrgUserByOrgSsoIdAsync(string orgIdentifier, User user, IUserService userService);
Task<OrganizationUser> AcceptOrgUserByOrgIdAsync(Guid organizationId, User user, IUserService userService);
Task<OrganizationUser> AcceptOrgUserAsync(OrganizationUser orgUser, User user, IUserService userService);
}

View File

@@ -40,15 +40,6 @@ public interface IOrganizationService
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups);
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
/// <summary>
/// Moves an OrganizationUser into the Accepted status and marks their email as verified.
/// This method is used where the user has clicked the invitation link sent by email.
/// </summary>
/// <param name="token">The token embedded in the email invitation link</param>
/// <returns>The accepted OrganizationUser.</returns>
Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token, IUserService userService);
Task<OrganizationUser> AcceptUserAsync(string orgIdentifier, User user, IUserService userService);
Task<OrganizationUser> AcceptUserAsync(Guid organizationId, User user, IUserService userService);
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,

View File

@@ -37,7 +37,6 @@ public interface IUserService
Task<IdentityResult> ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword,
string token, string key);
Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key);
Task<IdentityResult> SetPasswordAsync(User user, string newMasterPassword, string key, string orgIdentifier = null);
Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier);
Task<IdentityResult> ConvertToKeyConnectorAsync(User user);
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);
@@ -78,6 +77,9 @@ public interface IUserService
Task<bool> TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user);
Task<bool> TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user);
Task<string> GenerateSignInTokenAsync(User user, string purpose);
Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
bool validatePassword = true, bool refreshStamp = true);
Task RotateApiKeyAsync(User user);
string GetUserName(ClaimsPrincipal principal);
Task SendOTPAsync(User user);

View File

@@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
@@ -18,11 +19,11 @@ using Bit.Core.Models.Data.Organizations.Policies;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging;
using Stripe;
@@ -35,7 +36,6 @@ public class OrganizationService : IOrganizationService
private readonly ICollectionRepository _collectionRepository;
private readonly IUserRepository _userRepository;
private readonly IGroupRepository _groupRepository;
private readonly IDataProtector _dataProtector;
private readonly IMailService _mailService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IPushRegistrationService _pushRegistrationService;
@@ -57,6 +57,8 @@ public class OrganizationService : IOrganizationService
private readonly IProviderUserRepository _providerUserRepository;
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
@@ -67,7 +69,6 @@ public class OrganizationService : IOrganizationService
ICollectionRepository collectionRepository,
IUserRepository userRepository,
IGroupRepository groupRepository,
IDataProtectionProvider dataProtectionProvider,
IMailService mailService,
IPushNotificationService pushNotificationService,
IPushRegistrationService pushRegistrationService,
@@ -88,6 +89,8 @@ public class OrganizationService : IOrganizationService
IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IFeatureService featureService)
{
@@ -96,7 +99,6 @@ public class OrganizationService : IOrganizationService
_collectionRepository = collectionRepository;
_userRepository = userRepository;
_groupRepository = groupRepository;
_dataProtector = dataProtectionProvider.CreateProtector("OrganizationServiceDataProtector");
_mailService = mailService;
_pushNotificationService = pushNotificationService;
_pushRegistrationService = pushRegistrationService;
@@ -118,6 +120,8 @@ public class OrganizationService : IOrganizationService
_providerUserRepository = providerUserRepository;
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
}
@@ -1066,176 +1070,33 @@ public class OrganizationService : IOrganizationService
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
{
string MakeToken(OrganizationUser orgUser) =>
_dataProtector.Protect($"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
}
await _mailService.BulkSendOrganizationInviteEmailAsync(organization.Name,
orgUsers.Select(o => (o, new ExpiringToken(MakeToken(o), DateTime.UtcNow.AddDays(5)))), organization.PlanType == PlanType.Free);
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
await _mailService.BulkSendOrganizationInviteEmailAsync(
organization.Name,
orgUsersWithExpTokens,
organization.PlanType == PlanType.Free
);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
var now = DateTime.UtcNow;
var nowMillis = CoreHelpers.ToEpocMilliseconds(now);
var token = _dataProtector.Protect(
$"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {nowMillis}");
await _mailService.SendOrganizationInviteEmailAsync(organization.Name, orgUser, new ExpiringToken(token, now.AddDays(5)), organization.PlanType == PlanType.Free, initOrganization);
}
public async Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token,
IUserService userService)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (orgUser == null)
{
throw new BadRequestException("User invalid.");
}
if (!CoreHelpers.UserInviteTokenIsValid(_dataProtector, token, user.Email, orgUser.Id, _globalSettings))
{
throw new BadRequestException("Invalid token.");
}
var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync(
orgUser.OrganizationId, user.Email, true);
if (existingOrgUserCount > 0)
{
if (orgUser.Status == OrganizationUserStatusType.Accepted)
{
throw new BadRequestException("Invitation already accepted. You will receive an email when your organization membership is confirmed.");
}
throw new BadRequestException("You are already part of this organization.");
}
if (string.IsNullOrWhiteSpace(orgUser.Email) ||
!orgUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("User email does not match invite.");
}
var organizationUser = await AcceptUserAsync(orgUser, user, userService);
if (user.EmailVerified == false)
{
user.EmailVerified = true;
await _userRepository.ReplaceAsync(user);
}
return organizationUser;
}
public async Task<OrganizationUser> AcceptUserAsync(string orgIdentifier, User user, IUserService userService)
{
var org = await _organizationRepository.GetByIdentifierAsync(orgIdentifier);
if (org == null)
{
throw new BadRequestException("Organization invalid.");
}
var usersOrgs = await _organizationUserRepository.GetManyByUserAsync(user.Id);
var orgUser = usersOrgs.FirstOrDefault(u => u.OrganizationId == org.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
return await AcceptUserAsync(orgUser, user, userService);
}
public async Task<OrganizationUser> AcceptUserAsync(Guid organizationId, User user, IUserService userService)
{
var org = await _organizationRepository.GetByIdAsync(organizationId);
if (org == null)
{
throw new BadRequestException("Organization invalid.");
}
var usersOrgs = await _organizationUserRepository.GetManyByUserAsync(user.Id);
var orgUser = usersOrgs.FirstOrDefault(u => u.OrganizationId == org.Id);
if (orgUser == null)
{
throw new BadRequestException("User not found within organization.");
}
return await AcceptUserAsync(orgUser, user, userService);
}
private async Task<OrganizationUser> AcceptUserAsync(OrganizationUser orgUser, User user,
IUserService userService)
{
if (orgUser.Status == OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Your organization access has been revoked.");
}
if (orgUser.Status != OrganizationUserStatusType.Invited)
{
throw new BadRequestException("Already accepted.");
}
if (orgUser.Type == OrganizationUserType.Owner || orgUser.Type == OrganizationUserType.Admin)
{
var org = await GetOrgById(orgUser.OrganizationId);
if (org.PlanType == PlanType.Free)
{
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(
user.Id);
if (adminCount > 0)
{
throw new BadRequestException("You can only be an admin of one free organization.");
}
}
}
// Enforce Single Organization Policy of organization user is trying to join
var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(user.Id);
var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId);
var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id,
PolicyType.SingleOrg, OrganizationUserStatusType.Invited);
if (hasOtherOrgs && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
{
throw new BadRequestException("You may not join this organization until you leave or remove " +
"all other organizations.");
}
// Enforce Single Organization Policy of other organizations user is a member of
var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id,
PolicyType.SingleOrg);
if (anySingleOrgPolicies)
{
throw new BadRequestException("You cannot join this organization because you are a member of " +
"another organization which forbids it");
}
// Enforce Two Factor Authentication Policy of organization user is trying to join
if (!await userService.TwoFactorIsEnabledAsync(user))
{
var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id,
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited);
if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
{
throw new BadRequestException("You cannot join this organization until you enable " +
"two-step login on your user account.");
}
}
orgUser.Status = OrganizationUserStatusType.Accepted;
orgUser.UserId = user.Id;
orgUser.Email = null;
await _organizationUserRepository.ReplaceAsync(orgUser);
var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin);
var adminEmails = admins.Select(a => a.Email).Distinct().ToList();
if (adminEmails.Count > 0)
{
var organization = await _organizationRepository.GetByIdAsync(orgUser.OrganizationId);
await _mailService.SendOrganizationAcceptedEmailAsync(organization, user.Email, adminEmails);
}
return orgUser;
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
await _mailService.SendOrganizationInviteEmailAsync(
organization.Name,
orgUser,
new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate),
organization.PlanType == PlanType.Free,
initOrganization
);
}
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,

View File

@@ -11,6 +11,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Tokens;
@@ -57,11 +58,12 @@ public class UserService : UserManager<User>, IUserService, IDisposable
private readonly IFido2 _fido2;
private readonly ICurrentContext _currentContext;
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationService _organizationService;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IStripeSyncService _stripeSyncService;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IDataProtectorTokenFactory<WebAuthnLoginTokenable> _webAuthnLoginTokenizer;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
public UserService(
IUserRepository userRepository,
@@ -90,9 +92,10 @@ public class UserService : UserManager<User>, IUserService, IDisposable
IFido2 fido2,
ICurrentContext currentContext,
IGlobalSettings globalSettings,
IOrganizationService organizationService,
IAcceptOrgUserCommand acceptOrgUserCommand,
IProviderUserRepository providerUserRepository,
IStripeSyncService stripeSyncService,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IWebAuthnCredentialRepository webAuthnRepository,
IDataProtectorTokenFactory<WebAuthnLoginTokenable> webAuthnLoginTokenizer)
: base(
@@ -128,9 +131,10 @@ public class UserService : UserManager<User>, IUserService, IDisposable
_fido2 = fido2;
_currentContext = currentContext;
_globalSettings = globalSettings;
_organizationService = organizationService;
_acceptOrgUserCommand = acceptOrgUserCommand;
_providerUserRepository = providerUserRepository;
_stripeSyncService = stripeSyncService;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_webAuthnCredentialRepository = webAuthnRepository;
_webAuthnLoginTokenizer = webAuthnLoginTokenizer;
}
@@ -298,8 +302,13 @@ public class UserService : UserManager<User>, IUserService, IDisposable
var tokenValid = false;
if (_globalSettings.DisableUserRegistration && !string.IsNullOrWhiteSpace(token) && orgUserId.HasValue)
{
tokenValid = CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token,
user.Email, orgUserId.Value, _globalSettings);
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, token, orgUserId.Value, user.Email);
tokenValid = newTokenValid ||
CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token,
user.Email, orgUserId.Value, _globalSettings);
}
if (_globalSettings.DisableUserRegistration && !tokenValid)
@@ -730,11 +739,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return IdentityResult.Success;
}
public override Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword)
{
throw new NotImplementedException();
}
public async Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint,
string key)
{
@@ -768,40 +772,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
}
public async Task<IdentityResult> SetPasswordAsync(User user, string masterPassword, string key,
string orgIdentifier = null)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (!string.IsNullOrWhiteSpace(user.MasterPassword))
{
Logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id);
return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword());
}
var result = await UpdatePasswordHash(user, masterPassword, true, false);
if (!result.Succeeded)
{
return result;
}
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
user.Key = key;
await _userRepository.ReplaceAsync(user);
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
if (!string.IsNullOrWhiteSpace(orgIdentifier))
{
await _organizationService.AcceptUserAsync(orgIdentifier, user, this);
}
return IdentityResult.Success;
}
public async Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier)
{
var identityResult = CheckCanUseKeyConnector(user);
@@ -817,7 +787,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
await _userRepository.ReplaceAsync(user);
await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
await _organizationService.AcceptUserAsync(orgIdentifier, user, this);
await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(orgIdentifier, user, this);
return IdentityResult.Success;
}
@@ -1482,7 +1452,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return token;
}
private async Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
public async Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
bool validatePassword = true, bool refreshStamp = true)
{
if (validatePassword)

View File

@@ -7,7 +7,16 @@ public abstract class ExpiringTokenable : Tokenable
{
[JsonConverter(typeof(EpochDateTimeJsonConverter))]
public DateTime ExpirationDate { get; set; }
/// <summary>
/// Checks if the token is still within its valid duration and if its data is valid.
/// <para>For data validation, this property relies on the <see cref="TokenIsValid"/> method.</para>
/// </summary>
public override bool Valid => ExpirationDate > DateTime.UtcNow && TokenIsValid();
/// <summary>
/// Validates that the token data properties are correct.
/// <para>For expiration checks, refer to the <see cref="Valid"/> property.</para>
/// </summary>
protected abstract bool TokenIsValid();
}

View File

@@ -495,6 +495,7 @@ public static class CoreHelpers
return string.Concat("Custom_", type.ToString());
}
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
public static bool UserInviteTokenIsValid(IDataProtector protector, string token, string userEmail,
Guid orgUserId, IGlobalSettings globalSettings)
{

View File

@@ -13,6 +13,7 @@ using Bit.Core.Auth.LoginFeatures;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.Services.Implementations;
using Bit.Core.Auth.UserFeatures;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.HostedServices;
@@ -127,7 +128,7 @@ public static class ServiceCollectionExtensions
public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings)
{
services.AddScoped<ICipherService, CipherService>();
services.AddScoped<IUserService, UserService>();
services.AddUserServices(globalSettings);
services.AddOrganizationServices(globalSettings);
services.AddScoped<ICollectionService, CollectionService>();
services.AddScoped<IGroupService, GroupService>();
@@ -152,6 +153,7 @@ public static class ServiceCollectionExtensions
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<EmergencyAccessInviteTokenable>>>())
);
services.AddSingleton<IDataProtectorTokenFactory<HCaptchaTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<HCaptchaTokenable>(
HCaptchaTokenable.ClearTextPrefix,
@@ -159,6 +161,7 @@ public static class ServiceCollectionExtensions
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<HCaptchaTokenable>>>())
);
services.AddSingleton<IDataProtectorTokenFactory<SsoTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<SsoTokenable>(
SsoTokenable.ClearTextPrefix,
@@ -183,6 +186,14 @@ public static class ServiceCollectionExtensions
SsoEmail2faSessionTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<SsoEmail2faSessionTokenable>>>()));
services.AddSingleton<IOrgUserInviteTokenableFactory, OrgUserInviteTokenableFactory>();
services.AddSingleton<IDataProtectorTokenFactory<OrgUserInviteTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<OrgUserInviteTokenable>(
OrgUserInviteTokenable.ClearTextPrefix,
OrgUserInviteTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<OrgUserInviteTokenable>>>()));
}
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)