1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-14 22:27:32 -05:00

Merge branch 'master' into feature/flexible-collections

This commit is contained in:
Vincent Salucci
2023-08-24 10:28:10 -05:00
63 changed files with 3154 additions and 9504 deletions

View File

@ -5,6 +5,7 @@ using Bit.Admin.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
@ -199,6 +200,14 @@ public class OrganizationsController : Controller
{
var organization = await GetOrganization(id, model);
if (organization.UseSecretsManager &&
!organization.SecretsManagerBeta
&& StaticStore.GetSecretsManagerPlan(organization.PlanType) == null
)
{
throw new BadRequestException("Plan does not support Secrets Manager");
}
await _organizationRepository.ReplaceAsync(organization);
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)

View File

@ -28,9 +28,9 @@ public class OrganizationEditModel : OrganizationViewModel
public OrganizationEditModel(Organization org, Provider provider, IEnumerable<OrganizationUserUserDetails> orgUsers,
IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections, IEnumerable<Group> groups,
IEnumerable<Policy> policies, BillingInfo billingInfo, IEnumerable<OrganizationConnection> connections,
GlobalSettings globalSettings, int secrets, int projects, int serviceAccounts, int smSeats)
GlobalSettings globalSettings, int secrets, int projects, int serviceAccounts, int occupiedSmSeats)
: base(org, provider, connections, orgUsers, ciphers, collections, groups, policies, secrets, projects,
serviceAccounts, smSeats)
serviceAccounts, occupiedSmSeats)
{
BillingInfo = billingInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
@ -145,13 +145,26 @@ public class OrganizationEditModel : OrganizationViewModel
public int? SmSeats { get; set; }
[Display(Name = "Max Autoscale Seats")]
public int? MaxAutoscaleSmSeats { get; set; }
[Display(Name = "Max Service Accounts")]
[Display(Name = "Service Accounts")]
public int? SmServiceAccounts { get; set; }
[Display(Name = "Max Autoscale Service Accounts")]
public int? MaxAutoscaleSmServiceAccounts { get; set; }
[Display(Name = "Secrets Manager Beta")]
public bool SecretsManagerBeta { get; set; }
/**
* Creates a Plan[] object for use in Javascript
* This is mapped manually below to provide some type safety in case the plan objects change
* Add mappings for individual properties as you need them
*/
public IEnumerable<Dictionary<string, object>> GetPlansHelper() =>
StaticStore.SecretManagerPlans.Select(p =>
new Dictionary<string, object>
{
{ "type", p.Type },
{ "baseServiceAccount", p.BaseServiceAccount }
});
public Organization CreateOrganization(Provider provider)
{
BillingEmail = provider.BillingEmail;

View File

@ -13,7 +13,7 @@ public class OrganizationViewModel
public OrganizationViewModel(Organization org, Provider provider, IEnumerable<OrganizationConnection> connections,
IEnumerable<OrganizationUserUserDetails> orgUsers, IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
IEnumerable<Group> groups, IEnumerable<Policy> policies, int secretsCount, int projectCount, int serviceAccountsCount,
int smSeatsCount)
int occupiedSmSeatsCount)
{
Organization = org;
@ -39,10 +39,10 @@ public class OrganizationViewModel
orgUsers
.Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus)
.Select(u => u.Email));
Secrets = secretsCount;
Projects = projectCount;
ServiceAccounts = serviceAccountsCount;
SmSeats = smSeatsCount;
SecretsCount = secretsCount;
ProjectsCount = projectCount;
ServiceAccountsCount = serviceAccountsCount;
OccupiedSmSeatsCount = occupiedSmSeatsCount;
}
public Organization Organization { get; set; }
@ -59,9 +59,9 @@ public class OrganizationViewModel
public int GroupCount { get; set; }
public int PolicyCount { get; set; }
public bool HasPublicPrivateKeys { get; set; }
public int Secrets { get; set; }
public int Projects { get; set; }
public int ServiceAccounts { get; set; }
public int SmSeats { get; set; }
public int SecretsCount { get; set; }
public int ProjectsCount { get; set; }
public int ServiceAccountsCount { get; set; }
public int OccupiedSmSeatsCount { get; set; }
public bool UseSecretsManager => Organization.UseSecretsManager;
}

View File

@ -12,25 +12,45 @@
@section Scripts {
@await Html.PartialAsync("_OrganizationFormScripts")
<script>
(() => {
document.getElementById('teams-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)Bit.Core.Enums.PlanType.Free)') {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
togglePlanSettings('@((byte)Bit.Core.Enums.PlanType.TeamsAnnually)');
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
});
document.getElementById('enterprise-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)Bit.Core.Enums.PlanType.Free)') {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
togglePlanSettings('@((byte)Bit.Core.Enums.PlanType.EnterpriseAnnually)');
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
});
function setTrialDefaults(planType) {
// Plan
document.getElementById('@(nameof(Model.PlanType))').value = planType;
// Password Manager
document.getElementById('@(nameof(Model.Seats))').value = '10';
document.getElementById('@(nameof(Model.MaxCollections))').value = '';
document.getElementById('@(nameof(Model.MaxStorageGb))').value = '1';
// Secret Manager
if (document.getElementById('@(nameof(Model.UseSecretsManager))').checked) {
document.getElementById('@(nameof(Model.SmSeats))').value = '10';
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = getPlan(planType)?.baseServiceAccount;
}
// Licensing
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.ExpirationDate))').value = '@Model.FourteenDayExpirationDate';
document.getElementById('@(nameof(Model.SalesAssistedTrialStarted))').value = true;
}
})();
</script>
}

View File

@ -33,16 +33,16 @@
<dd class="col-sm-8 col-lg-9">@Model.CollectionCount</dd>
<dt class="col-sm-4 col-lg-3">Secrets</dt>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.Secrets: "N/A")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.SecretsCount: "N/A")</dd>
<dt class="col-sm-4 col-lg-3">Projects</dt>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.Projects: "N/A")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.ProjectsCount: "N/A")</dd>
<dt class="col-sm-4 col-lg-3">Service Accounts</dt>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.ServiceAccounts: "N/A")</dd>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.ServiceAccountsCount: "N/A")</dd>
<dt class="col-sm-4 col-lg-3">Secrets Manager Seats</dt>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.SmSeats: "N/A" )</dd>
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.OccupiedSmSeatsCount: "N/A" )</dd>
<dt class="col-sm-4 col-lg-3">Groups</dt>
<dd class="col-sm-8 col-lg-9">@Model.GroupCount</dd>

View File

@ -5,10 +5,10 @@
@section Scripts {
@await Html.PartialAsync("_OrganizationFormScripts")
<script>
(() => {
togglePlanSettings('@((byte)Model.PlanType)');
togglePlanFeatures('@((byte)Model.PlanType)');
})();
</script>
}

View File

@ -83,7 +83,7 @@
var planTypes = Enum.GetValues<PlanType>()
.Where(p => Model.Provider == null || p is >= PlanType.TeamsMonthly and <= PlanType.EnterpriseAnnually)
.Select(e => new SelectListItem
{
{
Value = ((int)e).ToString(),
Text = e.GetDisplayAttribute()?.GetName() ?? e.ToString()
})
@ -176,7 +176,7 @@
</div>
</div>
}
@if (canViewPlan)
{
<h2>Password Manager Configuration</h2>
@ -212,7 +212,7 @@
@if (canViewPlan)
{
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager)">
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager || Model.SecretsManagerBeta)">
<h2>Secrets Manager Configuration</h2>
<div class="row">
<div class="col-sm">
@ -265,7 +265,7 @@
}
@if (canViewBilling)
{
{
<h2>Billing</h2>
<div class="row">
<div class="col-sm">
@ -315,7 +315,7 @@
<label asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
@if (canLaunchGateway)
{
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
@ -328,4 +328,4 @@
</div>
</div>
}
</form>
</form>

View File

@ -1,10 +1,12 @@
@model OrganizationEditModel
<script>
(() => {
document.getElementById('@(nameof(Model.PlanType))').addEventListener('change', () => {
const selectEl = document.getElementById('@(nameof(Model.PlanType))');
const selectText = selectEl.options[selectEl.selectedIndex].text;
document.getElementById('@(nameof(Model.Plan))').value = selectText;
togglePlanSettings(selectEl.options[selectEl.selectedIndex].value);
togglePlanFeatures(selectEl.options[selectEl.selectedIndex].value);
});
document.getElementById('gateway-customer-link')?.addEventListener('click', () => {
const gateway = document.getElementById('@(nameof(Model.Gateway))');
@ -12,9 +14,9 @@
if (!gateway || gateway.value === '' || !customerId || customerId.value === '') {
return;
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
if (gateway.value === '@((byte)GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/customers/' + customerId.value, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
} else if (gateway.value === '@((byte)GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/'
+ customerId.value, '_blank');
}
@ -25,41 +27,45 @@
if (!gateway || gateway.value === '' || !subId || subId.value === '') {
return;
}
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
if (gateway.value === '@((byte)GatewayType.Stripe)') {
window.open('https://dashboard.stripe.com/subscriptions/' + subId.value, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
} else if (gateway.value === '@((byte)GatewayType.Braintree)') {
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
'subscriptions/' + subId.value, '_blank');
}
});
document.getElementById('@(nameof(Model.UseSecretsManager))').addEventListener('change', (event) => {
document.getElementById('organization-secrets-configuration').hidden = !event.target.checked;
document.getElementById('organization-secrets-configuration').hidden = !event.target.checked;
if (event.target.checked) {
setInitialSecretsManagerConfiguration();
return;
}
document.getElementById('@(nameof(Model.SmSeats))').value = '';
document.getElementById('@(nameof(Model.MaxAutoscaleSmSeats))').value = '';
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = '';
document.getElementById('@(nameof(Model.MaxAutoscaleSmServiceAccounts))').value = '';
// SM beta requires SM access
document.getElementById('@(nameof(Model.SecretsManagerBeta))').checked = false;
clearSecretsManagerConfiguration();
});
document.getElementById('@(nameof(Model.SecretsManagerBeta))').addEventListener('change', (event) => {
document.getElementById('organization-secrets-configuration').hidden = event.target.checked;
if (event.target.checked) {
// SM beta requires SM access
document.getElementById('@(nameof(Model.UseSecretsManager))').checked = true;
// SM Beta orgs do not have subscription limits
clearSecretsManagerConfiguration();
return;
}
setInitialSecretsManagerConfiguration();
});
})();
function togglePlanSettings(planType) {
document.getElementById('@(nameof(Model.PlanType))').value = planType;
function togglePlanFeatures(planType) {
switch(planType) {
case '@((byte)Bit.Core.Enums.PlanType.TeamsMonthly)':
case '@((byte)Bit.Core.Enums.PlanType.TeamsAnnually)':
// Plan
document.getElementById('@(nameof(Model.Seats))').value = '10';
document.getElementById('@(nameof(Model.MaxCollections))').value = '';
document.getElementById('@(nameof(Model.MaxStorageGb))').value = '1';
// Secrets
if (document.getElementById('@(nameof(Model.UseSecretsManager))').checked) {
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = '50';
}
// Features
case '@((byte)PlanType.TeamsMonthly)':
case '@((byte)PlanType.TeamsAnnually)':
document.getElementById('@(nameof(Model.UsePolicies))').checked = false;
document.getElementById('@(nameof(Model.UseSso))').checked = false;
document.getElementById('@(nameof(Model.UseGroups))').checked = true;
@ -73,23 +79,10 @@
document.getElementById('@(nameof(Model.SelfHost))').checked = false;
document.getElementById('@(nameof(Model.UseResetPassword))').checked = false;
document.getElementById('@(nameof(Model.UseScim))').checked = false;
// Licensing
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.ExpirationDate))').value = '@Model.FourteenDayExpirationDate';
document.getElementById('@(nameof(Model.SalesAssistedTrialStarted))').value = true;
break;
case '@((byte)Bit.Core.Enums.PlanType.EnterpriseMonthly)':
case '@((byte)Bit.Core.Enums.PlanType.EnterpriseAnnually)':
// Plan
document.getElementById('@(nameof(Model.Seats))').value = '10';
document.getElementById('@(nameof(Model.MaxCollections))').value = '';
document.getElementById('@(nameof(Model.MaxStorageGb))').value = '1';
// Secrets
if (document.getElementById('@(nameof(Model.UseSecretsManager))').checked) {
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = '200';
}
// Features
case '@((byte)PlanType.EnterpriseMonthly)':
case '@((byte)PlanType.EnterpriseAnnually)':
document.getElementById('@(nameof(Model.UsePolicies))').checked = true;
document.getElementById('@(nameof(Model.UseSso))').checked = true;
document.getElementById('@(nameof(Model.UseGroups))').checked = true;
@ -103,12 +96,41 @@
document.getElementById('@(nameof(Model.SelfHost))').checked = true;
document.getElementById('@(nameof(Model.UseResetPassword))').checked = true;
document.getElementById('@(nameof(Model.UseScim))').checked = true;
// Licensing
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.ExpirationDate))').value = '@Model.FourteenDayExpirationDate';
document.getElementById('@(nameof(Model.SalesAssistedTrialStarted))').value = true;
break;
}
}
/***
* Set Secrets Manager values based on current usage (for migrating from SM beta or reinstating an old subscription)
*/
function setInitialSecretsManagerConfiguration() {
const planType = document.getElementById('@(nameof(Model.PlanType))').value;
// Seats
document.getElementById('@(nameof(Model.SmSeats))').value = Math.max(@Model.OccupiedSmSeatsCount, 1);
// Service accounts
const baseServiceAccounts = getPlan(planType)?.baseServiceAccount ?? 0;
if (planType !== '@((byte)PlanType.Free)' && @Model.ServiceAccountsCount > baseServiceAccounts) {
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = @Model.ServiceAccountsCount;
} else {
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = baseServiceAccounts;
}
// Clear autoscale values (no defaults)
document.getElementById('@(nameof(Model.MaxAutoscaleSmSeats))').value = '';
document.getElementById('@(nameof(Model.MaxAutoscaleSmServiceAccounts))').value = '';
}
function clearSecretsManagerConfiguration() {
document.getElementById('@(nameof(Model.SmSeats))').value = '';
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = '';
document.getElementById('@(nameof(Model.MaxAutoscaleSmSeats))').value = '';
document.getElementById('@(nameof(Model.MaxAutoscaleSmServiceAccounts))').value = '';
}
function getPlan(planType) {
const plans = @Html.Raw(Json.Serialize(Model.GetPlansHelper()));
return plans.find(p => p.type == planType);
}
</script>

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Auth.Services;
using Bit.Core.Exceptions;
@ -72,6 +73,18 @@ public class AuthRequestsController : Controller
[HttpPost("")]
[AllowAnonymous]
public async Task<AuthRequestResponseModel> Post([FromBody] AuthRequestCreateRequestModel model)
{
if (model.Type == AuthRequestType.AdminApproval)
{
throw new BadRequestException("You must be authenticated to create a request of that type.");
}
var authRequest = await _authRequestService.CreateAuthRequestAsync(model);
var r = new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault);
return r;
}
[HttpPost("admin-request")]
public async Task<AuthRequestResponseModel> PostAdminRequest([FromBody] AuthRequestCreateRequestModel model)
{
var authRequest = await _authRequestService.CreateAuthRequestAsync(model);
var r = new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault);

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core.Auth.Models.Api.Request;
#nullable enable
namespace Bit.Api.Auth.Models.Request;
public class UpdateDevicesTrustRequestModel : SecretVerificationRequestModel
{
[Required]
public DeviceKeysUpdateRequestModel CurrentDevice { get; set; } = null!;
public IEnumerable<OtherDeviceKeysUpdateRequestModel>? OtherDevices { get; set; }
}

View File

@ -879,10 +879,6 @@ public class AccountsController : Controller
public async Task PostRequestOTP()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user is not { UsesKeyConnector: true })
{
throw new UnauthorizedAccessException();
}
await _userService.SendOTPAsync(user);
}
@ -891,10 +887,6 @@ public class AccountsController : Controller
public async Task VerifyOTP([FromBody] VerifyOTPRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user is not { UsesKeyConnector: true })
{
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifyOTPAsync(user, model.OTP))
{

View File

@ -1,7 +1,11 @@
using Bit.Api.Models.Request;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
@ -19,17 +23,20 @@ public class DevicesController : Controller
private readonly IDeviceService _deviceService;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext;
public DevicesController(
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService,
IUserRepository userRepository)
IUserRepository userRepository,
ICurrentContext currentContext)
{
_deviceRepository = deviceRepository;
_deviceService = deviceService;
_userService = userService;
_userRepository = userRepository;
_currentContext = currentContext;
}
[HttpGet("{id}")]
@ -66,15 +73,6 @@ public class DevicesController : Controller
return new ListResponseModel<DeviceResponseModel>(responses);
}
[HttpPost("exist-by-types")]
public async Task<ActionResult<bool>> GetExistenceByTypes([FromBody] DeviceType[] deviceTypes)
{
var userId = _userService.GetProperUserId(User).Value;
var devices = await _deviceRepository.GetManyByUserIdAsync(userId);
var userHasDeviceOfTypes = devices.Any(d => deviceTypes.Contains(d.Type));
return Ok(userHasDeviceOfTypes);
}
[HttpPost("")]
public async Task<DeviceResponseModel> Post([FromBody] DeviceRequestModel model)
{
@ -117,6 +115,55 @@ public class DevicesController : Controller
return response;
}
[HttpPost("{identifier}/retrieve-keys")]
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier, [FromBody] SecretVerificationRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
if (device == null)
{
throw new NotFoundException();
}
return new ProtectedDeviceResponseModel(device);
}
[HttpPost("update-trust")]
public async Task PostUpdateTrust([FromBody] UpdateDevicesTrustRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
await _deviceService.UpdateDevicesTrustAsync(
_currentContext.DeviceIdentifier,
user.Id,
model.CurrentDevice,
model.OtherDevices ?? Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>());
}
[HttpPut("identifier/{identifier}/token")]
[HttpPost("identifier/{identifier}/token")]
public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model)

View File

@ -2,7 +2,6 @@
using Bit.Api.Models.Response;
using Bit.Api.Models.Response.Organizations;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
@ -313,7 +312,7 @@ public class OrganizationUsersController : Controller
}
[HttpPut("{userId}/reset-password-enrollment")]
public async Task PutResetPasswordEnrollment(string orgId, string userId, [FromBody] OrganizationUserResetPasswordEnrollmentRequestModel model)
public async Task PutResetPasswordEnrollment(Guid orgId, Guid userId, [FromBody] OrganizationUserResetPasswordEnrollmentRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
@ -321,16 +320,14 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException();
}
if (model.ResetPasswordKey != null && !await _userService.VerifySecretAsync(user, model.Secret))
var callingUserId = user.Id;
await _organizationService.UpdateUserResetPasswordEnrollmentAsync(
orgId, userId, model.ResetPasswordKey, callingUserId);
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, user.Id);
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
await Task.Delay(2000);
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
}
else
{
var callingUserId = user.Id;
await _organizationService.UpdateUserResetPasswordEnrollmentAsync(
new Guid(orgId), new Guid(userId), model.ResetPasswordKey, callingUserId);
await _organizationService.AcceptUserAsync(orgId, user, _userService);
}
}
@ -466,7 +463,7 @@ public class OrganizationUsersController : Controller
private async Task RestoreOrRevokeUserAsync(
Guid orgId,
Guid id,
Func<OrganizationUser, Guid?, Task> statusAction)
Func<Core.Entities.OrganizationUser, Guid?, Task> statusAction)
{
if (!await _currentContext.ManageUsers(orgId))
{
@ -486,7 +483,7 @@ public class OrganizationUsersController : Controller
private async Task<ListResponseModel<OrganizationUserBulkResponseModel>> RestoreOrRevokeUsersAsync(
Guid orgId,
OrganizationUserBulkRequestModel model,
Func<Guid, IEnumerable<Guid>, Guid?, Task<List<Tuple<OrganizationUser, string>>>> statusAction)
Func<Guid, IEnumerable<Guid>, Guid?, Task<List<Tuple<Core.Entities.OrganizationUser, string>>>> statusAction)
{
if (!await _currentContext.ManageUsers(orgId))
{

View File

@ -682,8 +682,8 @@ public class OrganizationsController : Controller
await _paymentService.SaveTaxInfoAsync(organization, taxInfo);
}
[HttpGet("{id}/keys")]
public async Task<OrganizationKeysResponseModel> GetKeys(string id)
[HttpGet("{id}/public-key")]
public async Task<OrganizationPublicKeyResponseModel> GetPublicKey(string id)
{
var org = await _organizationRepository.GetByIdAsync(new Guid(id));
if (org == null)
@ -691,7 +691,14 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
return new OrganizationKeysResponseModel(org);
return new OrganizationPublicKeyResponseModel(org);
}
[Obsolete("TDL-136 Renamed to public-key (2023.8), left for backwards compatability with older clients.")]
[HttpGet("{id}/keys")]
public async Task<OrganizationPublicKeyResponseModel> GetKeys(string id)
{
return await GetPublicKey(id);
}
[HttpPost("{id}/keys")]

View File

@ -1,6 +0,0 @@
namespace Bit.Api.Models.Request.Organizations;
public class OrganizationEnrollSecretsManagerRequestModel
{
public bool Enabled { get; set; }
}

View File

@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@ -108,7 +107,7 @@ public class OrganizationUserUpdateGroupsRequestModel
public IEnumerable<string> GroupIds { get; set; }
}
public class OrganizationUserResetPasswordEnrollmentRequestModel : SecretVerificationRequestModel
public class OrganizationUserResetPasswordEnrollmentRequestModel
{
public string ResetPasswordKey { get; set; }
}

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Auth.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
@ -19,9 +20,7 @@ public class DeviceResponseModel : ResponseModel
Type = device.Type;
Identifier = device.Identifier;
CreationDate = device.CreationDate;
EncryptedUserKey = device.EncryptedUserKey;
EncryptedPublicKey = device.EncryptedPublicKey;
EncryptedPrivateKey = device.EncryptedPrivateKey;
IsTrusted = device.IsTrusted();
}
public Guid Id { get; set; }
@ -29,7 +28,5 @@ public class DeviceResponseModel : ResponseModel
public DeviceType Type { get; set; }
public string Identifier { get; set; }
public DateTime CreationDate { get; set; }
public string EncryptedUserKey { get; }
public string EncryptedPublicKey { get; }
public string EncryptedPrivateKey { get; }
public bool IsTrusted { get; set; }
}

View File

@ -0,0 +1,19 @@
using Bit.Core.Entities;
using Bit.Core.Models.Api;
namespace Bit.Api.Models.Response.Organizations;
public class OrganizationPublicKeyResponseModel : ResponseModel
{
public OrganizationPublicKeyResponseModel(Organization org) : base("organizationPublicKey")
{
if (org == null)
{
throw new ArgumentNullException(nameof(org));
}
PublicKey = org.PublicKey;
}
public string PublicKey { get; set; }
}

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request;
public class OtherDeviceKeysUpdateRequestModel : DeviceKeysUpdateRequestModel
{
[Required]
public Guid DeviceId { get; set; }
}
public class DeviceKeysUpdateRequestModel
{
[Required]
[EncryptedString]
public string EncryptedPublicKey { get; set; }
[Required]
[EncryptedString]
public string EncryptedUserKey { get; set; }
}

View File

@ -0,0 +1,30 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
namespace Bit.Core.Auth.Models.Api.Response;
public class ProtectedDeviceResponseModel : ResponseModel
{
public ProtectedDeviceResponseModel(Device device)
: base("protectedDevice")
{
ArgumentNullException.ThrowIfNull(device);
Id = device.Id;
Name = device.Name;
Type = device.Type;
Identifier = device.Identifier;
CreationDate = device.CreationDate;
EncryptedUserKey = device.EncryptedUserKey;
EncryptedPublicKey = device.EncryptedPublicKey;
}
public Guid Id { get; set; }
public string Name { get; set; }
public DeviceType Type { get; set; }
public string Identifier { get; set; }
public DateTime CreationDate { get; set; }
public string EncryptedUserKey { get; set; }
public string EncryptedPublicKey { get; set; }
}

View File

@ -32,10 +32,22 @@ public class UserDecryptionOptions : ResponseModel
public class TrustedDeviceUserDecryptionOption
{
public bool HasAdminApproval { get; }
public bool HasLoginApprovingDevice { get; }
public bool HasManageResetPasswordPermission { get; }
public string? EncryptedPrivateKey { get; }
public string? EncryptedUserKey { get; }
public TrustedDeviceUserDecryptionOption(bool hasAdminApproval)
public TrustedDeviceUserDecryptionOption(bool hasAdminApproval,
bool hasLoginApprovingDevice,
bool hasManageResetPasswordPermission,
string? encryptedPrivateKey,
string? encryptedUserKey)
{
HasAdminApproval = hasAdminApproval;
HasLoginApprovingDevice = hasLoginApprovingDevice;
HasManageResetPasswordPermission = hasManageResetPasswordPermission;
EncryptedPrivateKey = encryptedPrivateKey;
EncryptedUserKey = encryptedUserKey;
}
}

View File

@ -1,8 +1,11 @@
using Bit.Core.Auth.Entities;
using System.Diagnostics;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Exceptions;
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
@ -21,6 +24,8 @@ public class AuthRequestService : IAuthRequestService
private readonly IDeviceRepository _deviceRepository;
private readonly ICurrentContext _currentContext;
private readonly IPushNotificationService _pushNotificationService;
private readonly IEventService _eventService;
private readonly IOrganizationUserRepository _organizationUserRepository;
public AuthRequestService(
IAuthRequestRepository authRequestRepository,
@ -28,7 +33,9 @@ public class AuthRequestService : IAuthRequestService
IGlobalSettings globalSettings,
IDeviceRepository deviceRepository,
ICurrentContext currentContext,
IPushNotificationService pushNotificationService)
IPushNotificationService pushNotificationService,
IEventService eventService,
IOrganizationUserRepository organizationRepository)
{
_authRequestRepository = authRequestRepository;
_userRepository = userRepository;
@ -36,6 +43,8 @@ public class AuthRequestService : IAuthRequestService
_deviceRepository = deviceRepository;
_currentContext = currentContext;
_pushNotificationService = pushNotificationService;
_eventService = eventService;
_organizationUserRepository = organizationRepository;
}
public async Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId)
@ -52,9 +61,12 @@ public class AuthRequestService : IAuthRequestService
public async Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code)
{
var authRequest = await _authRequestRepository.GetByIdAsync(id);
if (authRequest == null ||
!CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code) ||
authRequest.GetExpirationDate() < DateTime.UtcNow)
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code))
{
return null;
}
if (!IsAuthRequestValid(authRequest))
{
return null;
}
@ -91,6 +103,42 @@ public class AuthRequestService : IAuthRequestService
}
}
// AdminApproval requests require correlating the user and their organization
if (model.Type == AuthRequestType.AdminApproval)
{
// TODO: When single org policy is turned on we should query for only a single organization from the current user
// and create only an AuthRequest for that organization and return only that one
// This will send out the request to all organizations this user belongs to
var organizationUsers = await _organizationUserRepository.GetManyByUserAsync(_currentContext.UserId!.Value);
if (organizationUsers.Count == 0)
{
throw new BadRequestException("User does not belong to any organizations.");
}
// A user event will automatically create logs for each organization/provider this user belongs to.
await _eventService.LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval);
AuthRequest? firstAuthRequest = null;
foreach (var organizationUser in organizationUsers)
{
var createdAuthRequest = await CreateAuthRequestAsync(model, user, organizationUser.OrganizationId);
firstAuthRequest ??= createdAuthRequest;
}
// I know this won't be null because I have already validated that at least one organization exists
return firstAuthRequest!;
}
var authRequest = await CreateAuthRequestAsync(model, user, organizationId: null);
await _pushNotificationService.PushAuthRequestAsync(authRequest);
return authRequest;
}
private async Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model, User user, Guid? organizationId)
{
Debug.Assert(_currentContext.DeviceType.HasValue, "DeviceType should have already been validated to have a value.");
var authRequest = new AuthRequest
{
RequestDeviceIdentifier = model.DeviceIdentifier,
@ -100,35 +148,58 @@ public class AuthRequestService : IAuthRequestService
PublicKey = model.PublicKey,
UserId = user.Id,
Type = model.Type.GetValueOrDefault(),
OrganizationId = organizationId,
};
authRequest = await _authRequestRepository.CreateAsync(authRequest);
await _pushNotificationService.PushAuthRequestAsync(authRequest);
return authRequest;
}
public async Task<AuthRequest> UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model)
public async Task<AuthRequest> UpdateAuthRequestAsync(Guid authRequestId, Guid currentUserId, AuthRequestUpdateRequestModel model)
{
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
if (authRequest == null || authRequest.UserId != userId || authRequest.GetExpirationDate() < DateTime.UtcNow)
if (authRequest == null)
{
throw new NotFoundException();
}
// Once Approval/Disapproval has been set, this AuthRequest should not be updated again.
if (authRequest.Approved is not null)
{
throw new DuplicateAuthRequestException();
}
// Admin approval responses are not tied to a specific device, so we don't need to validate it.
if (authRequest.Type != AuthRequestType.AdminApproval)
// Do type specific validation
switch (authRequest.Type)
{
var device = await _deviceRepository.GetByIdentifierAsync(model.DeviceIdentifier, userId);
if (device == null)
{
throw new BadRequestException("Invalid device.");
}
authRequest.ResponseDeviceId = device.Id;
case AuthRequestType.AdminApproval:
// AdminApproval has a different expiration time, by default is 7 days compared to
// non-AdminApproval ones having a default of 15 minutes.
if (IsDateExpired(authRequest.CreationDate, _globalSettings.PasswordlessAuth.AdminRequestExpiration))
{
throw new NotFoundException();
}
break;
case AuthRequestType.AuthenticateAndUnlock:
case AuthRequestType.Unlock:
if (IsDateExpired(authRequest.CreationDate, _globalSettings.PasswordlessAuth.UserRequestExpiration))
{
throw new NotFoundException();
}
if (authRequest.UserId != currentUserId)
{
throw new NotFoundException();
}
// Admin approval responses are not tied to a specific device, but these types are so we need to validate them
var device = await _deviceRepository.GetByIdentifierAsync(model.DeviceIdentifier, currentUserId);
if (device == null)
{
throw new BadRequestException("Invalid device.");
}
authRequest.ResponseDeviceId = device.Id;
break;
}
authRequest.ResponseDate = DateTime.UtcNow;
@ -146,9 +217,55 @@ public class AuthRequestService : IAuthRequestService
// to not leak that it was denied to the originating client if it was originated by a malicious actor.
if (authRequest.Approved ?? true)
{
if (authRequest.OrganizationId.HasValue)
{
var organizationUser = await _organizationUserRepository
.GetByOrganizationAsync(authRequest.OrganizationId.Value, authRequest.UserId);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_ApprovedAuthRequest);
}
// No matter what we want to push out the success notification
await _pushNotificationService.PushAuthRequestResponseAsync(authRequest);
}
// If the request is rejected by an organization admin then we want to log an event of that action
else if (authRequest.Approved.HasValue && !authRequest.Approved.Value && authRequest.OrganizationId.HasValue)
{
var organizationUser = await _organizationUserRepository
.GetByOrganizationAsync(authRequest.OrganizationId.Value, authRequest.UserId);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_RejectedAuthRequest);
}
return authRequest;
}
private bool IsAuthRequestValid(AuthRequest authRequest)
{
return authRequest.Type switch
{
AuthRequestType.AuthenticateAndUnlock or AuthRequestType.Unlock
=> !IsDateExpired(authRequest.CreationDate, _globalSettings.PasswordlessAuth.UserRequestExpiration),
AuthRequestType.AdminApproval => IsAdminApprovalAuthRequestValid(authRequest),
_ => false,
};
}
private bool IsAdminApprovalAuthRequestValid(AuthRequest authRequest)
{
Debug.Assert(authRequest.Type == AuthRequestType.AdminApproval, "This method should only be called on AdminApproval type");
// If an AdminApproval type has been approved it's expiration time is based on how long it's been since approved.
if (authRequest.Approved is true)
{
Debug.Assert(authRequest.ResponseDate.HasValue, "The response date should have been set when the request was updated.");
return !IsDateExpired(authRequest.ResponseDate.Value, _globalSettings.PasswordlessAuth.AfterAdminApprovalExpiration);
}
else
{
return !IsDateExpired(authRequest.CreationDate, _globalSettings.PasswordlessAuth.AdminRequestExpiration);
}
}
private static bool IsDateExpired(DateTime savedDate, TimeSpan allowedLifetime)
{
return DateTime.UtcNow > savedDate.Add(allowedLifetime);
}
}

View File

@ -63,7 +63,7 @@ public class SsoConfigService : ISsoConfigService
throw new BadRequestException("Key Connector cannot be disabled at this moment.");
}
// Automatically enable account recovery and single org policies if trusted device encryption is selected
// Automatically enable account recovery, SSO required, and single org policies if trusted device encryption is selected
if (config.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption)
{
var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg) ??
@ -78,8 +78,13 @@ public class SsoConfigService : ISsoConfigService
resetPolicy.Enabled = true;
resetPolicy.SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true });
await _policyService.SaveAsync(resetPolicy, _userService, _organizationService, null);
var ssoRequiredPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso) ??
new Policy { OrganizationId = config.OrganizationId, Type = PolicyType.RequireSso, };
ssoRequiredPolicy.Enabled = true;
await _policyService.SaveAsync(ssoRequiredPolicy, _userService, _organizationService, null);
}
await LogEventsAsync(config, oldConfig);

View File

@ -0,0 +1,23 @@
using Bit.Core.Entities;
#nullable enable
namespace Bit.Core.Auth.Utilities;
public static class DeviceExtensions
{
/// <summary>
/// Gets a boolean representing if the device has enough information on it to determine whether or not it is trusted.
/// </summary>
/// <remarks>
/// It is possible for a device to be un-trusted client side and not notify the server side. This should not be
/// the source of truth for whether a device is fully trusted and should just be considered that, to the server,
/// a device has the necessary information to be "trusted".
/// </remarks>
public static bool IsTrusted(this Device device)
{
return !string.IsNullOrEmpty(device.EncryptedUserKey) &&
!string.IsNullOrEmpty(device.EncryptedPublicKey) &&
!string.IsNullOrEmpty(device.EncryptedPrivateKey);
}
}

View File

@ -213,4 +213,9 @@ public class User : ITableObject<Guid>, ISubscriber, IStorable, IStorableSubscri
SecurityStamp = SecurityStamp
};
}
public bool HasMasterPassword()
{
return MasterPassword != null;
}
}

View File

@ -13,6 +13,7 @@ public enum EventType : int
User_ClientExportedVault = 1007,
User_UpdatedTempPassword = 1008,
User_MigratedKeyToKeyConnector = 1009,
User_RequestedDeviceApproval = 1010,
Cipher_Created = 1100,
Cipher_Updated = 1101,
@ -54,6 +55,8 @@ public enum EventType : int
OrganizationUser_FirstSsoLogin = 1510,
OrganizationUser_Revoked = 1511,
OrganizationUser_Restored = 1512,
OrganizationUser_ApprovedAuthRequest = 1513,
OrganizationUser_RejectedAuthRequest = 1514,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@ -39,6 +39,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<IEnumerable<OrganizationUserUserDetails>> GetManyByMinimumRoleAsync(Guid organizationId, OrganizationUserType minRole);
Task RevokeAsync(Guid id);
Task RestoreAsync(Guid id, OrganizationUserStatusType status);
Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId);
Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType);
Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId);
}

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Entities;
namespace Bit.Core.Services;
@ -7,4 +8,8 @@ public interface IDeviceService
Task SaveAsync(Device device);
Task ClearTokenAsync(Device device);
Task DeleteAsync(Device device);
Task UpdateDevicesTrustAsync(string currentDeviceIdentifier,
Guid currentUserId,
DeviceKeysUpdateRequestModel currentDeviceUpdate,
IEnumerable<OtherDeviceKeysUpdateRequestModel> alteredDevices);
}

View File

@ -41,6 +41,7 @@ public interface IOrganizationService
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
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

@ -1,4 +1,7 @@
using Bit.Core.Entities;
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Utilities;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
namespace Bit.Core.Services;
@ -43,4 +46,61 @@ public class DeviceService : IDeviceService
await _deviceRepository.DeleteAsync(device);
await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString());
}
public async Task UpdateDevicesTrustAsync(string currentDeviceIdentifier,
Guid currentUserId,
DeviceKeysUpdateRequestModel currentDeviceUpdate,
IEnumerable<OtherDeviceKeysUpdateRequestModel> alteredDevices)
{
var existingDevices = await _deviceRepository.GetManyByUserIdAsync(currentUserId);
var currentDevice = existingDevices.FirstOrDefault(d => d.Identifier == currentDeviceIdentifier);
if (currentDevice == null)
{
throw new NotFoundException();
}
existingDevices.Remove(currentDevice);
var alterDeviceKeysDict = alteredDevices.ToDictionary(d => d.DeviceId);
if (alterDeviceKeysDict.ContainsKey(currentDevice.Id))
{
throw new BadRequestException("Current device can not be an optional rotation.");
}
currentDevice.EncryptedPublicKey = currentDeviceUpdate.EncryptedPublicKey;
currentDevice.EncryptedUserKey = currentDeviceUpdate.EncryptedUserKey;
await _deviceRepository.UpsertAsync(currentDevice);
foreach (var device in existingDevices)
{
if (!device.IsTrusted())
{
// You can't update the trust of a device that isn't trusted to begin with
// should we throw and consider this a BadRequest? If we want to consider it a invalid request
// we need to check that information before we enter this foreach, we don't want to partially complete
// this process.
continue;
}
if (alterDeviceKeysDict.TryGetValue(device.Id, out var updateRequest))
{
// An update to this device was requested
device.EncryptedPublicKey = updateRequest.EncryptedPublicKey;
device.EncryptedUserKey = updateRequest.EncryptedUserKey;
}
else
{
// No update to this device requested, just untrust it
device.EncryptedUserKey = null;
device.EncryptedPublicKey = null;
device.EncryptedPrivateKey = null;
}
await _deviceRepository.UpsertAsync(device);
}
}
}

View File

@ -1114,6 +1114,24 @@ public class OrganizationService : IOrganizationService
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)
{

View File

@ -20,8 +20,6 @@ public class PolicyService : IPolicyService
private readonly IMailService _mailService;
private readonly GlobalSettings _globalSettings;
private IEnumerable<OrganizationUserPolicyDetails> _cachedOrganizationUserPolicyDetails;
public PolicyService(
IEventService eventService,
IOrganizationRepository organizationRepository,
@ -75,6 +73,7 @@ public class PolicyService : IPolicyService
else
{
await RequiredByKeyConnectorAsync(org);
await RequiredBySsoTrustedDeviceEncryptionAsync(org);
}
break;
@ -196,25 +195,18 @@ public class PolicyService : IPolicyService
return result.Any();
}
private async Task<IEnumerable<OrganizationUserPolicyDetails>> QueryOrganizationUserPolicyDetailsAsync(Guid userId, PolicyType? policyType, OrganizationUserStatusType minStatus = OrganizationUserStatusType.Accepted)
private async Task<IEnumerable<OrganizationUserPolicyDetails>> QueryOrganizationUserPolicyDetailsAsync(Guid userId, PolicyType policyType, OrganizationUserStatusType minStatus = OrganizationUserStatusType.Accepted)
{
// Check if the cached policies are available
if (_cachedOrganizationUserPolicyDetails == null)
{
// Cached policies not available, retrieve from the repository
_cachedOrganizationUserPolicyDetails = await _organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(userId);
}
var organizationUserPolicyDetails = await _organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(userId, policyType);
var excludedUserTypes = GetUserTypesExcludedFromPolicy(policyType);
return _cachedOrganizationUserPolicyDetails.Where(o =>
(policyType == null || o.PolicyType == policyType) &&
return organizationUserPolicyDetails.Where(o =>
o.PolicyEnabled &&
!excludedUserTypes.Contains(o.OrganizationUserType) &&
o.OrganizationUserStatus >= minStatus &&
!o.IsProvider);
}
private OrganizationUserType[] GetUserTypesExcludedFromPolicy(PolicyType? policyType)
private OrganizationUserType[] GetUserTypesExcludedFromPolicy(PolicyType policyType)
{
switch (policyType)
{

View File

@ -892,7 +892,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
await _userRepository.ReplaceAsync(user);
}
await _pushService.PushLogOutAsync(user.Id);
await _pushService.PushLogOutAsync(user.Id, excludeCurrentContextFromPush: true);
return IdentityResult.Success;
}
@ -1455,26 +1455,35 @@ public class UserService : UserManager<User>, IUserService, IDisposable
throw new BadRequestException("No user email.");
}
if (!user.UsesKeyConnector)
{
throw new BadRequestException("Not using Key Connector.");
}
var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider,
"otp:" + user.Email);
await _mailService.SendOTPEmailAsync(user.Email, token);
}
public Task<bool> VerifyOTPAsync(User user, string token)
public async Task<bool> VerifyOTPAsync(User user, string token)
{
return base.VerifyUserTokenAsync(user, TokenOptions.DefaultEmailProvider,
return await base.VerifyUserTokenAsync(user, TokenOptions.DefaultEmailProvider,
"otp:" + user.Email, token);
}
public async Task<bool> VerifySecretAsync(User user, string secret)
{
return user.UsesKeyConnector
? await VerifyOTPAsync(user, secret)
: await CheckPasswordAsync(user, secret);
bool isVerified;
if (user.HasMasterPassword())
{
// If the user has a master password the secret is most likely going to be a hash
// of their password, but in certain scenarios, like when the user has logged into their
// device without a password (trusted device encryption) but the account
// does still have a password we will allow the use of OTP.
isVerified = await CheckPasswordAsync(user, secret) ||
await VerifyOTPAsync(user, secret);
}
else
{
// If they don't have a password at all they can only do OTP
isVerified = await VerifyOTPAsync(user, secret);
}
return isVerified;
}
}

View File

@ -0,0 +1,21 @@
using Bit.Core.Enums;
namespace Bit.Core.Utilities;
public static class DeviceTypes
{
public static IReadOnlyCollection<DeviceType> MobileTypes { get; } = new[]
{
DeviceType.Android,
DeviceType.iOS,
DeviceType.AndroidAmazon,
};
public static IReadOnlyCollection<DeviceType> DesktopTypes { get; } = new[]
{
DeviceType.LinuxDesktop,
DeviceType.MacOsDesktop,
DeviceType.WindowsDesktop,
DeviceType.UWP,
};
}

View File

@ -14,7 +14,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Identity.Controllers;
// TODO: 2022-01-12, Remove account alias
// TODO: 2023-10-16, Remove account alias (https://bitwarden.atlassian.net/browse/PM-1247)
[Route("account/[action]")]
[Route("sso/[action]")]
public class SsoController : Controller

View File

@ -3,13 +3,14 @@ using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -22,6 +23,7 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Identity.Utilities;
using IdentityServer4.Validation;
using Microsoft.AspNetCore.Identity;
@ -207,7 +209,7 @@ public abstract class BaseRequestValidator<T> where T : class
customResponse.Add("KdfIterations", user.KdfIterations);
customResponse.Add("KdfMemory", user.KdfMemory);
customResponse.Add("KdfParallelism", user.KdfParallelism);
customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, GetSubject(context)));
customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
if (sendRememberToken)
{
@ -350,7 +352,8 @@ public abstract class BaseRequestValidator<T> where T : class
return true;
}
// Check if user belongs to any organization with an active SSO policy
// Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser)
{
@ -587,15 +590,17 @@ public abstract class BaseRequestValidator<T> where T : class
/// <summary>
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
/// </summary>
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(User user, ClaimsPrincipal subject)
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject)
{
var ssoConfigurationData = await GetSsoConfigurationDataAsync(subject);
var ssoConfiguration = await GetSsoConfigurationDataAsync(subject);
var userDecryptionOption = new UserDecryptionOptions
{
HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword)
};
var ssoConfigurationData = ssoConfiguration?.GetData();
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
{
// KeyConnector makes it mutually exclusive
@ -606,15 +611,51 @@ public abstract class BaseRequestValidator<T> where T : class
// Only add the trusted device specific option when the flag is turned on
if (FeatureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext) && ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption })
{
var hasAdminApproval = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.ResetPassword);
string? encryptedPrivateKey = null;
string? encryptedUserKey = null;
if (device.IsTrusted())
{
encryptedPrivateKey = device.EncryptedPrivateKey;
encryptedUserKey = device.EncryptedUserKey;
}
var allDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id);
// Checks if the current user has any devices that are capable of approving login with device requests except for
// their current device.
// NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting.
var hasLoginApprovingDevice = allDevices
.Where(d => d.Identifier != device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type))
.Any();
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
var hasManageResetPasswordPermission = false;
// when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
if (CurrentContext.Organizations.Any(o => o.Id == ssoConfiguration!.OrganizationId))
{
// TDE requires single org so grabbing first org & id is fine.
hasManageResetPasswordPermission = await CurrentContext.ManageResetPassword(ssoConfiguration!.OrganizationId);
}
// If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(ssoConfiguration!.OrganizationId, user.Id);
// They are only able to be approved by an admin if they have enrolled is reset password
var hasAdminApproval = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
// TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true
userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(hasAdminApproval);
userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(
hasAdminApproval,
hasLoginApprovingDevice,
hasManageResetPasswordPermission,
encryptedPrivateKey,
encryptedUserKey);
}
return userDecryptionOption;
}
private async Task<SsoConfigurationData?> GetSsoConfigurationDataAsync(ClaimsPrincipal subject)
private async Task<SsoConfig?> GetSsoConfigurationDataAsync(ClaimsPrincipal subject)
{
var organizationClaim = subject?.FindFirstValue("organizationId");
@ -629,6 +670,6 @@ public abstract class BaseRequestValidator<T> where T : class
return null;
}
return ssoConfig.GetData();
return ssoConfig;
}
}

View File

@ -0,0 +1,19 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Identity.Utilities;
public static class LoginApprovingDeviceTypes
{
private static readonly IReadOnlyCollection<DeviceType> _deviceTypes;
static LoginApprovingDeviceTypes()
{
var deviceTypes = new List<DeviceType>();
deviceTypes.AddRange(DeviceTypes.DesktopTypes);
deviceTypes.AddRange(DeviceTypes.MobileTypes);
_deviceTypes = deviceTypes.AsReadOnly();
}
public static IReadOnlyCollection<DeviceType> Types => _deviceTypes;
}

View File

@ -506,13 +506,13 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
}
}
public async Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId)
public async Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<OrganizationUserPolicyDetails>(
$"[{Schema}].[{Table}_ReadByUserIdWithPolicyDetails]",
new { UserId = userId },
new { UserId = userId, PolicyType = policyType },
commandType: CommandType.StoredProcedure);
return results.ToList();

View File

@ -594,7 +594,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
}
}
public async Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId)
public async Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
@ -610,7 +610,8 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
join ou in dbContext.OrganizationUsers
on p.OrganizationId equals ou.OrganizationId
let email = dbContext.Users.Find(userId).Email // Invited orgUsers do not have a UserId associated with them, so we have to match up their email
where ou.UserId == userId || ou.Email == email
where p.Type == policyType &&
(ou.UserId == userId || ou.Email == email)
select new OrganizationUserPolicyDetails
{
OrganizationUserId = ou.Id,

View File

@ -155,6 +155,7 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
dbContext.Ciphers.RemoveRange(dbContext.Ciphers.Where(c => c.UserId == user.Id));
dbContext.Folders.RemoveRange(dbContext.Folders.Where(f => f.UserId == user.Id));
dbContext.AuthRequests.RemoveRange(dbContext.AuthRequests.Where(s => s.UserId == user.Id));
dbContext.Devices.RemoveRange(dbContext.Devices.Where(d => d.UserId == user.Id));
var collectionUsers = from cu in dbContext.CollectionUsers
join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id

View File

@ -1,5 +1,6 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ReadByUserIdWithPolicyDetails]
@UserId UNIQUEIDENTIFIER
@UserId UNIQUEIDENTIFIER,
@PolicyType TINYINT
AS
BEGIN
SET NOCOUNT ON
@ -12,19 +13,22 @@ SELECT
OU.[Type] AS OrganizationUserType,
OU.[Status] AS OrganizationUserStatus,
OU.[Permissions] AS OrganizationUserPermissionsData,
CASE WHEN PU.[ProviderId] IS NOT NULL THEN 1 ELSE 0 END AS IsProvider
CASE WHEN EXISTS (
SELECT 1
FROM [dbo].[ProviderUserView] PU
INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId]
WHERE PU.[UserId] = OU.[UserId] AND PO.[OrganizationId] = P.[OrganizationId]
) THEN 1 ELSE 0 END AS IsProvider
FROM [dbo].[PolicyView] P
INNER JOIN [dbo].[OrganizationUserView] OU
ON P.[OrganizationId] = OU.[OrganizationId]
LEFT JOIN [dbo].[ProviderUserView] PU
ON PU.[UserId] = OU.[UserId]
LEFT JOIN [dbo].[ProviderOrganizationView] PO
ON PO.[ProviderId] = PU.[ProviderId] AND PO.[OrganizationId] = P.[OrganizationId]
WHERE
(OU.[Status] != 0 AND OU.[UserId] = @UserId) -- OrgUsers who have accepted their invite and are linked to a UserId
OR EXISTS (
SELECT 1
FROM [dbo].[UserView] U
WHERE U.[Id] = @UserId AND OU.[Email] = U.[Email] AND OU.[Status] = 0 -- 'Invited' OrgUsers are not linked to a UserId yet, so we have to look up their email
WHERE P.[Type] = @PolicyType AND
(
(OU.[Status] != 0 AND OU.[UserId] = @UserId) -- OrgUsers who have accepted their invite and are linked to a UserId
OR EXISTS (
SELECT 1
FROM [dbo].[UserView] U
WHERE U.[Id] = @UserId AND OU.[Email] = U.[Email] AND OU.[Status] = 0 -- 'Invited' OrgUsers are not linked to a UserId yet, so we have to look up their email
)
)
END
END

View File

@ -31,6 +31,13 @@ BEGIN
WHERE
[UserId] = @Id
-- Delete AuthRequest, must be before Device
DELETE
FROM
[dbo].[AuthRequest]
WHERE
[UserId] = @Id
-- Delete devices
DELETE
FROM
@ -43,7 +50,7 @@ BEGIN
CU
FROM
[dbo].[CollectionUser] CU
INNER JOIN
INNER JOIN
[dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId]
WHERE
OU.[UserId] = @Id
@ -53,7 +60,7 @@ BEGIN
GU
FROM
[dbo].[GroupUser] GU
INNER JOIN
INNER JOIN
[dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId]
WHERE
OU.[UserId] = @Id
@ -63,7 +70,7 @@ BEGIN
AP
FROM
[dbo].[AccessPolicy] AP
INNER JOIN
INNER JOIN
[dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId]
WHERE
[UserId] = @Id
@ -95,7 +102,7 @@ BEGIN
[dbo].[EmergencyAccess]
WHERE
[GrantorId] = @Id
OR
OR
[GranteeId] = @Id
-- Delete Sends
@ -104,7 +111,7 @@ BEGIN
[dbo].[Send]
WHERE
[UserId] = @Id
-- Finally, delete the user
DELETE
FROM