mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12:49 -05:00
Merge branch 'master' into feature/flexible-collections
This commit is contained in:
7
.github/renovate.json
vendored
7
.github/renovate.json
vendored
@ -44,5 +44,10 @@
|
||||
"matchManagers": ["nuget"],
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"force": {
|
||||
"constraints": {
|
||||
"dotnet": "6.0.413"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Download latest Release ${{ matrix.name }} asset
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@f096207b7a2f31723165aee6ad03e91716686e78
|
||||
uses: bitwarden/gh-actions/download-artifacts@bc3bf31f1d9cac9c9d02cae01fc615fa25d38929
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
@ -98,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Dry Run - Download latest Release ${{ matrix.name }} asset
|
||||
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@f096207b7a2f31723165aee6ad03e91716686e78
|
||||
uses: bitwarden/gh-actions/download-artifacts@bc3bf31f1d9cac9c9d02cae01fc615fa25d38929
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
@ -274,7 +274,7 @@ jobs:
|
||||
steps:
|
||||
- name: Download latest Release Docker Stubs
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@f096207b7a2f31723165aee6ad03e91716686e78
|
||||
uses: bitwarden/gh-actions/download-artifacts@bc3bf31f1d9cac9c9d02cae01fc615fa25d38929
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
@ -287,7 +287,7 @@ jobs:
|
||||
|
||||
- name: Dry Run - Download latest Release Docker Stubs
|
||||
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@f096207b7a2f31723165aee6ad03e91716686e78
|
||||
uses: bitwarden/gh-actions/download-artifacts@bc3bf31f1d9cac9c9d02cae01fc615fa25d38929
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Version>2023.7.2</Version>
|
||||
<Version>2023.8.1</Version>
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
4606
bitwarden_license/src/Sso/package-lock.json
generated
4606
bitwarden_license/src/Sso/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -5,10 +5,10 @@
|
||||
|
||||
@section Scripts {
|
||||
@await Html.PartialAsync("_OrganizationFormScripts")
|
||||
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
togglePlanSettings('@((byte)Model.PlanType)');
|
||||
togglePlanFeatures('@((byte)Model.PlanType)');
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
4615
src/Admin/package-lock.json
generated
4615
src/Admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
@ -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; }
|
||||
}
|
@ -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))
|
||||
{
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
{
|
||||
|
@ -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")]
|
||||
|
@ -1,6 +0,0 @@
|
||||
namespace Bit.Api.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationEnrollSecretsManagerRequestModel
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
23
src/Core/Auth/Utilities/DeviceExtensions.cs
Normal file
23
src/Core/Auth/Utilities/DeviceExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -213,4 +213,9 @@ public class User : ITableObject<Guid>, ISubscriber, IStorable, IStorableSubscri
|
||||
SecurityStamp = SecurityStamp
|
||||
};
|
||||
}
|
||||
|
||||
public bool HasMasterPassword()
|
||||
{
|
||||
return MasterPassword != null;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
21
src/Core/Utilities/DeviceTypes.cs
Normal file
21
src/Core/Utilities/DeviceTypes.cs
Normal 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,
|
||||
};
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
19
src/Identity/Utilities/LoginApprovingDeviceTypes.cs
Normal file
19
src/Identity/Utilities/LoginApprovingDeviceTypes.cs
Normal 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;
|
||||
}
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -16,6 +16,34 @@ namespace Bit.Api.Test.Controllers;
|
||||
[SutProviderCustomize]
|
||||
public class OrganizationUsersControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PutResetPasswordEnrollment_InivitedUser_AcceptsInvite(Guid orgId, Guid userId, OrganizationUserResetPasswordEnrollmentRequestModel model,
|
||||
User user, OrganizationUser orgUser, SutProvider<OrganizationUsersController> sutProvider)
|
||||
{
|
||||
orgUser.Status = Core.Enums.OrganizationUserStatusType.Invited;
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(default, default).ReturnsForAnyArgs(orgUser);
|
||||
|
||||
await sutProvider.Sut.PutResetPasswordEnrollment(orgId, userId, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).AcceptUserAsync(orgId, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PutResetPasswordEnrollment_ConfirmedUser_AcceptsInvite(Guid orgId, Guid userId, OrganizationUserResetPasswordEnrollmentRequestModel model,
|
||||
User user, OrganizationUser orgUser, SutProvider<OrganizationUsersController> sutProvider)
|
||||
{
|
||||
orgUser.Status = Core.Enums.OrganizationUserStatusType.Confirmed;
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(default, default).ReturnsForAnyArgs(orgUser);
|
||||
|
||||
await sutProvider.Sut.PutResetPasswordEnrollment(orgId, userId, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(0).AcceptUserAsync(orgId, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Accept_RequiresKnownUser(Guid orgId, Guid orgUserId, OrganizationUserAcceptRequestModel model,
|
||||
|
@ -1,4 +1,6 @@
|
||||
using System.Reflection;
|
||||
#nullable enable
|
||||
|
||||
using System.Reflection;
|
||||
using AutoFixture;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Xunit.Sdk;
|
||||
@ -9,19 +11,21 @@ namespace Bit.Test.Common.AutoFixture.Attributes;
|
||||
public class BitAutoDataAttribute : DataAttribute
|
||||
{
|
||||
private readonly Func<IFixture> _createFixture;
|
||||
private readonly object[] _fixedTestParameters;
|
||||
private readonly object?[] _fixedTestParameters;
|
||||
|
||||
public BitAutoDataAttribute(params object[] fixedTestParameters) :
|
||||
public BitAutoDataAttribute() : this(Array.Empty<object>()) { }
|
||||
|
||||
public BitAutoDataAttribute(params object?[] fixedTestParameters) :
|
||||
this(() => new Fixture(), fixedTestParameters)
|
||||
{ }
|
||||
|
||||
public BitAutoDataAttribute(Func<IFixture> createFixture, params object[] fixedTestParameters) :
|
||||
public BitAutoDataAttribute(Func<IFixture> createFixture, params object?[] fixedTestParameters) :
|
||||
base()
|
||||
{
|
||||
_createFixture = createFixture;
|
||||
_fixedTestParameters = fixedTestParameters;
|
||||
}
|
||||
|
||||
public override IEnumerable<object[]> GetData(MethodInfo testMethod)
|
||||
public override IEnumerable<object?[]> GetData(MethodInfo testMethod)
|
||||
=> BitAutoDataAttributeHelpers.GetData(testMethod, _createFixture(), _fixedTestParameters);
|
||||
}
|
||||
|
@ -103,6 +103,7 @@ public static class AssertHelper
|
||||
public static Expression<Predicate<T>> AssertEqualExpected<T>(T expected) =>
|
||||
(T actual) => AssertEqualExpectedPredicate(expected)(actual);
|
||||
|
||||
[StackTraceHidden]
|
||||
public static JsonElement AssertJsonProperty(JsonElement element, string propertyName, JsonValueKind jsonValueKind)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var subElement))
|
||||
|
@ -1,4 +1,7 @@
|
||||
using System.Reflection;
|
||||
#nullable enable
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using AutoFixture;
|
||||
using AutoFixture.Kernel;
|
||||
using AutoFixture.Xunit2;
|
||||
@ -8,18 +11,23 @@ namespace Bit.Test.Common.Helpers;
|
||||
|
||||
public static class BitAutoDataAttributeHelpers
|
||||
{
|
||||
public static IEnumerable<object[]> GetData(MethodInfo testMethod, IFixture fixture, object[] fixedTestParameters)
|
||||
public static IEnumerable<object?[]> GetData(MethodInfo testMethod, IFixture fixture, object?[] fixedTestParameters)
|
||||
{
|
||||
var methodParameters = testMethod.GetParameters();
|
||||
var classCustomizations = testMethod.DeclaringType.GetCustomAttributes<BitCustomizeAttribute>().Select(attr => attr.GetCustomization());
|
||||
// We aren't worried about a test method not having a class it belongs to.
|
||||
var classCustomizations = testMethod.DeclaringType!.GetCustomAttributes<BitCustomizeAttribute>().Select(attr => attr.GetCustomization());
|
||||
var methodCustomizations = testMethod.GetCustomAttributes<BitCustomizeAttribute>().Select(attr => attr.GetCustomization());
|
||||
|
||||
fixedTestParameters = fixedTestParameters ?? Array.Empty<object>();
|
||||
fixedTestParameters ??= Array.Empty<object>();
|
||||
|
||||
fixture = ApplyCustomizations(ApplyCustomizations(fixture, classCustomizations), methodCustomizations);
|
||||
|
||||
// The first n number of parameters should be match to the supplied parameters
|
||||
var fixedTestInputParameters = methodParameters.Take(fixedTestParameters.Length).Zip(fixedTestParameters);
|
||||
|
||||
var missingParameters = methodParameters.Skip(fixedTestParameters.Length).Select(p => CustomizeAndCreate(p, fixture));
|
||||
|
||||
return new object[1][] { fixedTestParameters.Concat(missingParameters).ToArray() };
|
||||
return new object?[1][] { ConvertFixedParameters(fixedTestInputParameters.ToArray()).Concat(missingParameters).ToArray() };
|
||||
}
|
||||
|
||||
public static object CustomizeAndCreate(ParameterInfo p, IFixture fixture)
|
||||
@ -48,4 +56,71 @@ public static class BitAutoDataAttributeHelpers
|
||||
|
||||
return newFixture;
|
||||
}
|
||||
|
||||
public static IEnumerable<object?> ConvertFixedParameters((ParameterInfo Parameter, object? Value)[] fixedParameters)
|
||||
{
|
||||
var output = new object?[fixedParameters.Length];
|
||||
for (var i = 0; i < fixedParameters.Length; i++)
|
||||
{
|
||||
var (parameter, value) = fixedParameters[i];
|
||||
// If the value is null, just return the value
|
||||
if (value is null || value.GetType() == parameter.ParameterType)
|
||||
{
|
||||
output[i] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the value is a string and it's not a perfect match, try to convert it.
|
||||
if (value is string stringValue)
|
||||
{
|
||||
//
|
||||
if (parameter.ParameterType.IsGenericType && parameter.ParameterType.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
{
|
||||
if (TryConvertToType(stringValue, Nullable.GetUnderlyingType(parameter.ParameterType)!, out var nullableConvertedValue))
|
||||
{
|
||||
output[i] = nullableConvertedValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We couldn't convert it, so set it as the input value and let XUnit throw
|
||||
output[i] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryConvertToType(stringValue, parameter.ParameterType, out var convertedValue))
|
||||
{
|
||||
output[i] = convertedValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We couldn't convert it, so set it as the input value and let XUnit throw
|
||||
output[i] = value;
|
||||
}
|
||||
|
||||
// No easy conversion, give them back the value
|
||||
output[i] = value;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static bool TryConvertToType(string value, Type destinationType, out object? convertedValue)
|
||||
{
|
||||
convertedValue = null;
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var converter = TypeDescriptor.GetConverter(destinationType);
|
||||
|
||||
if (converter.CanConvertFrom(typeof(string)))
|
||||
{
|
||||
convertedValue = converter.ConvertFromInvariantString(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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.Auth.Services.Implementations;
|
||||
@ -11,6 +12,7 @@ using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
@ -69,12 +71,23 @@ public class AuthRequestServiceTests
|
||||
Assert.Null(foundAuthRequest);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
/// <summary>
|
||||
/// Story: AdminApproval AuthRequests should have a longer expiration time by default and non-AdminApproval ones
|
||||
/// should expire after 15 minutes by default.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AdminApproval, "-10.00:00:00")]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock, "-00:16:00")]
|
||||
[BitAutoData(AuthRequestType.Unlock, "-00:16:00")]
|
||||
public async Task GetValidatedAuthRequestAsync_IfExpired_ReturnsNull(
|
||||
AuthRequestType authRequestType,
|
||||
TimeSpan creationTimeBeforeNow,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddHours(-1);
|
||||
authRequest.Type = authRequestType;
|
||||
authRequest.CreationDate = DateTime.UtcNow.Add(creationTimeBeforeNow);
|
||||
authRequest.Approved = false;
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
@ -85,6 +98,29 @@ public class AuthRequestServiceTests
|
||||
Assert.Null(foundAuthRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: Once a AdminApproval type has been approved it has a different expiration time based on time
|
||||
/// after the response.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetValidatedAuthRequestAsync_AdminApprovalApproved_HasLongerExpiration_ReturnsRequest(
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
authRequest.Type = AuthRequestType.AdminApproval;
|
||||
authRequest.Approved = true;
|
||||
authRequest.ResponseDate = DateTime.UtcNow.Add(TimeSpan.FromHours(-13));
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
var validatedAuthRequest = await sutProvider.Sut.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode);
|
||||
|
||||
Assert.Null(validatedAuthRequest);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetValidatedAuthRequestAsync_IfValid_ReturnsAuthRequest(
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
@ -96,6 +132,10 @@ public class AuthRequestServiceTests
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.PasswordlessAuth
|
||||
.Returns(new Settings.GlobalSettings.PasswordlessAuthSettings());
|
||||
|
||||
var foundAuthRequest = await sutProvider.Sut.GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode);
|
||||
|
||||
Assert.NotNull(foundAuthRequest);
|
||||
@ -136,13 +176,22 @@ public class AuthRequestServiceTests
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAuthRequestAsync(createModel));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
/// <summary>
|
||||
/// Story: Non-AdminApproval requests should be created without a known device if the settings is set to <c>false</c>
|
||||
/// Non-AdminApproval ones should also have a push notification sent about them.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock)]
|
||||
[BitAutoData(AuthRequestType.Unlock)]
|
||||
[BitAutoData(new object?[1] { null })]
|
||||
public async Task CreateAuthRequestAsync_CreatesAuthRequest(
|
||||
AuthRequestType? authRequestType,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequestCreateRequestModel createModel,
|
||||
User user)
|
||||
{
|
||||
user.Email = createModel.Email;
|
||||
createModel.Type = authRequestType;
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByEmailAsync(createModel.Email)
|
||||
@ -152,28 +201,44 @@ public class AuthRequestServiceTests
|
||||
.DeviceType
|
||||
.Returns(DeviceType.Android);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.IpAddress
|
||||
.Returns("1.1.1.1");
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.PasswordlessAuth.KnownDevicesOnly
|
||||
.Returns(false);
|
||||
|
||||
await sutProvider.Sut.CreateAuthRequestAsync(createModel);
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.CreateAsync(Arg.Any<AuthRequest>())
|
||||
.Returns(c => c.ArgAt<AuthRequest>(0));
|
||||
|
||||
var createdAuthRequest = await sutProvider.Sut.CreateAuthRequestAsync(createModel);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received()
|
||||
.PushAuthRequestAsync(Arg.Any<AuthRequest>());
|
||||
.PushAuthRequestAsync(createdAuthRequest);
|
||||
|
||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.Received()
|
||||
.CreateAsync(Arg.Any<AuthRequest>());
|
||||
.CreateAsync(createdAuthRequest);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
/// <summary>
|
||||
/// Story: Since an AllowAnonymous endpoint calls this method we need
|
||||
/// to verify that a device was able to be found via ICurrentContext
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock)]
|
||||
[BitAutoData(AuthRequestType.Unlock)]
|
||||
public async Task CreateAuthRequestAsync_NoDeviceType_ThrowsBadRequest(
|
||||
AuthRequestType authRequestType,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequestCreateRequestModel createModel,
|
||||
User user)
|
||||
{
|
||||
user.Email = createModel.Email;
|
||||
createModel.Type = authRequestType;
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByEmailAsync(createModel.Email)
|
||||
@ -186,13 +251,92 @@ public class AuthRequestServiceTests
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAuthRequestAsync(createModel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: If a user happens to exist to more than one organization, we will send the device approval request to
|
||||
/// each of them.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAuthRequestAsync_AdminApproval_CreatesForEachOrganization(
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequestCreateRequestModel createModel,
|
||||
User user,
|
||||
OrganizationUser organizationUser1,
|
||||
OrganizationUser organizationUser2)
|
||||
{
|
||||
createModel.Type = AuthRequestType.AdminApproval;
|
||||
user.Email = createModel.Email;
|
||||
organizationUser1.UserId = user.Id;
|
||||
organizationUser2.UserId = user.Id;
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByEmailAsync(user.Email)
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.DeviceType
|
||||
.Returns(DeviceType.ChromeExtension);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.UserId
|
||||
.Returns(user.Id);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.PasswordlessAuth.KnownDevicesOnly
|
||||
.Returns(false);
|
||||
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(new List<OrganizationUser>
|
||||
{
|
||||
organizationUser1,
|
||||
organizationUser2,
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.CreateAsync(Arg.Any<AuthRequest>())
|
||||
.Returns(c => c.ArgAt<AuthRequest>(0));
|
||||
|
||||
var authRequest = await sutProvider.Sut.CreateAuthRequestAsync(createModel);
|
||||
|
||||
Assert.Equal(organizationUser1.OrganizationId, authRequest.OrganizationId);
|
||||
|
||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(Arg.Is<AuthRequest>(o => o.OrganizationId == organizationUser1.OrganizationId));
|
||||
|
||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(Arg.Is<AuthRequest>(o => o.OrganizationId == organizationUser2.OrganizationId));
|
||||
|
||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.Received(2)
|
||||
.CreateAsync(Arg.Any<AuthRequest>());
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: When an <see cref="AuthRequest"> is approved we want to update it in the database so it cannot have
|
||||
/// it's status changed again and we want to push a notification to let the user know of the approval.
|
||||
/// In the case of the AdminApproval we also want to log an event.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AdminApproval, "7b055ea1-38be-42d0-b2e4-becb2340f8df")]
|
||||
[BitAutoData(AuthRequestType.Unlock, null)]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock, null)]
|
||||
public async Task UpdateAuthRequestAsync_ValidResponse_SendsResponse(
|
||||
AuthRequestType authRequestType,
|
||||
Guid? organizationId,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
|
||||
authRequest.Approved = null;
|
||||
authRequest.OrganizationId = organizationId;
|
||||
authRequest.Type = authRequestType;
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
@ -208,6 +352,18 @@ public class AuthRequestServiceTests
|
||||
.GetByIdentifierAsync(device.Identifier, authRequest.UserId)
|
||||
.Returns(device);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>())
|
||||
.Returns(new OrganizationUser
|
||||
{
|
||||
UserId = authRequest.UserId,
|
||||
OrganizationId = organizationId.GetValueOrDefault(),
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.PasswordlessAuth
|
||||
.Returns(new Settings.GlobalSettings.PasswordlessAuthSettings());
|
||||
|
||||
var updateModel = new AuthRequestUpdateRequestModel
|
||||
{
|
||||
Key = "test_key",
|
||||
@ -220,37 +376,75 @@ public class AuthRequestServiceTests
|
||||
|
||||
Assert.Equal("my_hash", udpatedAuthRequest.MasterPasswordHash);
|
||||
|
||||
// On approval, the response date should be set to current date
|
||||
Assert.NotNull(udpatedAuthRequest.ResponseDate);
|
||||
AssertHelper.AssertRecent(udpatedAuthRequest.ResponseDate!.Value);
|
||||
|
||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.Received()
|
||||
.Received(1)
|
||||
.ReplaceAsync(udpatedAuthRequest);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received()
|
||||
.Received(1)
|
||||
.PushAuthRequestResponseAsync(udpatedAuthRequest);
|
||||
|
||||
var expectedNumberOfCalls = organizationId.HasValue ? 1 : 0;
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(expectedNumberOfCalls)
|
||||
.LogOrganizationUserEventAsync(
|
||||
Arg.Is<OrganizationUser>(ou => ou.UserId == authRequest.UserId && ou.OrganizationId == organizationId),
|
||||
EventType.OrganizationUser_ApprovedAuthRequest);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
/// <summary>
|
||||
/// Story: When an <see cref="AuthRequest"> is rejected we want to update it in the database so it cannot have
|
||||
/// it's status changed again but we do not want to send a push notification to the original device
|
||||
/// so as to not leak that it was rejected. In the case of an AdminApproval type we do want to log an event though
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AdminApproval, "7b055ea1-38be-42d0-b2e4-becb2340f8df")]
|
||||
[BitAutoData(AuthRequestType.Unlock, null)]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock, null)]
|
||||
public async Task UpdateAuthRequestAsync_ResponseNotApproved_DoesNotLeakRejection(
|
||||
AuthRequestType authRequestType,
|
||||
Guid? organizationId,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Give it a recent creation time which is valid for all types of AuthRequests
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
|
||||
authRequest.Type = authRequestType;
|
||||
// Has not been decided already
|
||||
authRequest.Approved = null;
|
||||
authRequest.OrganizationId = organizationId;
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
// Setup a device for all requests even though it will not be called for verification in a AdminApproval
|
||||
var device = new Device
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Identifier = "test_identifier",
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.PasswordlessAuth
|
||||
.Returns(new Settings.GlobalSettings.PasswordlessAuthSettings());
|
||||
|
||||
sutProvider.GetDependency<IDeviceRepository>()
|
||||
.GetByIdentifierAsync(device.Identifier, authRequest.UserId)
|
||||
.Returns(device);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>())
|
||||
.Returns(new OrganizationUser
|
||||
{
|
||||
UserId = authRequest.UserId,
|
||||
OrganizationId = organizationId.GetValueOrDefault(),
|
||||
});
|
||||
|
||||
var updateModel = new AuthRequestUpdateRequestModel
|
||||
{
|
||||
Key = "test_key",
|
||||
@ -262,6 +456,9 @@ public class AuthRequestServiceTests
|
||||
var udpatedAuthRequest = await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel);
|
||||
|
||||
Assert.Equal(udpatedAuthRequest.MasterPasswordHash, authRequest.MasterPasswordHash);
|
||||
Assert.False(udpatedAuthRequest.Approved);
|
||||
Assert.NotNull(udpatedAuthRequest.ResponseDate);
|
||||
AssertHelper.AssertRecent(udpatedAuthRequest.ResponseDate!.Value);
|
||||
|
||||
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.Received()
|
||||
@ -270,17 +467,37 @@ public class AuthRequestServiceTests
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.PushAuthRequestResponseAsync(udpatedAuthRequest);
|
||||
|
||||
var expectedNumberOfCalls = organizationId.HasValue ? 1 : 0;
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(expectedNumberOfCalls)
|
||||
.LogOrganizationUserEventAsync(
|
||||
Arg.Is<OrganizationUser>(ou => ou.UserId == authRequest.UserId && ou.OrganizationId == organizationId),
|
||||
EventType.OrganizationUser_RejectedAuthRequest);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
/// <summary>
|
||||
/// Story: A bad actor is able to get ahold of the request id of a valid <see cref="AuthRequest" />
|
||||
/// and tries to approve it from their own Bitwarden account. We need to validate that the currently signed in user
|
||||
/// is the same user that originally created the request and we want to pretend it does not exist at all by throwing
|
||||
/// NotFoundException.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock)]
|
||||
[BitAutoData(AuthRequestType.Unlock)]
|
||||
public async Task UpdateAuthRequestAsync_InvalidUser_ThrowsNotFound(
|
||||
AuthRequestType authRequestType,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest,
|
||||
Guid userId)
|
||||
Guid authenticatedUserId)
|
||||
{
|
||||
// Give it a recent creation date so that it is valid
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
|
||||
authRequest.Approved = false;
|
||||
// The request hasn't been Approved/Disapproved already
|
||||
authRequest.Approved = null;
|
||||
// Has an type that needs the UserId property validated
|
||||
authRequest.Type = authRequestType;
|
||||
|
||||
// Auth request should not be null
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
@ -297,23 +514,39 @@ public class AuthRequestServiceTests
|
||||
|
||||
// Give it a randomly generated userId such that it won't be valid for the AuthRequest
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, userId, updateModel));
|
||||
async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authenticatedUserId, updateModel));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
/// <summary>
|
||||
/// Story: A user created this auth request and does not approve/reject the request
|
||||
/// for 16 minutes, which is past the default expiration time. This auth request
|
||||
/// will be purged from the database soon but might exist for some amount of time after it's expiration
|
||||
/// this method should throw a NotFoundException since it theoretically should not exist, this
|
||||
/// could be a user finally clicking Approve after the request sitting on their phone for a while.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock, "-00:16:00")]
|
||||
[BitAutoData(AuthRequestType.Unlock, "-00:16:00")]
|
||||
[BitAutoData(AuthRequestType.AdminApproval, "-8.00:00:00")]
|
||||
public async Task UpdateAuthRequestAsync_OldAuthRequest_ThrowsNotFound(
|
||||
AuthRequestType authRequestType,
|
||||
TimeSpan timeBeforeCreation,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// AuthRequest's have a valid lifetime of only 15 minutes, make it older than that
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-16);
|
||||
authRequest.Approved = false;
|
||||
// AuthRequest's have a default valid lifetime of only 15 minutes, make it older than that
|
||||
authRequest.CreationDate = DateTime.UtcNow.Add(timeBeforeCreation);
|
||||
// Make it so that the user has not made a decision on this request
|
||||
authRequest.Approved = null;
|
||||
// Make it one of the types that doesn't have longer expiration i.e AdminApproval
|
||||
authRequest.Type = authRequestType;
|
||||
|
||||
// Auth request should not be null
|
||||
// The item should still exist in the database
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
// Represents the user finally clicking approve.
|
||||
var updateModel = new AuthRequestUpdateRequestModel
|
||||
{
|
||||
Key = "test_key",
|
||||
@ -322,27 +555,38 @@ public class AuthRequestServiceTests
|
||||
MasterPasswordHash = "my_hash",
|
||||
};
|
||||
|
||||
// Give it a randomly generated userId such that it won't be valid for the AuthRequest
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
/// <summary>
|
||||
/// Story: non-AdminApproval types need to validate that the device used to respond to the
|
||||
/// request is a known device to the authenticated user.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(AuthRequestType.AuthenticateAndUnlock)]
|
||||
[BitAutoData(AuthRequestType.Unlock)]
|
||||
public async Task UpdateAuthRequestAsync_InvalidDeviceIdentifier_ThrowsBadRequest(
|
||||
AuthRequestType authRequestType,
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
|
||||
authRequest.Approved = null;
|
||||
authRequest.Type = authRequestType;
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
sutProvider.GetDependency<IDeviceRepository>()
|
||||
.GetByIdentifierAsync(Arg.Any<string>(), authRequest.UserId)
|
||||
.GetByIdentifierAsync("invalid_identifier", authRequest.UserId)
|
||||
.Returns((Device?)null);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.PasswordlessAuth
|
||||
.Returns(new Settings.GlobalSettings.PasswordlessAuthSettings());
|
||||
|
||||
var updateModel = new AuthRequestUpdateRequestModel
|
||||
{
|
||||
Key = "test_key",
|
||||
@ -355,29 +599,21 @@ public class AuthRequestServiceTests
|
||||
async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: Once the destiny of an AuthRequest has been decided, it should be considered immutable
|
||||
/// and new update request should be blocked.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAuthRequestAsync_AlreadyApprovedOrRejected_ThrowsDuplicateAuthRequestException(
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest)
|
||||
{
|
||||
// Set CreationDate to a valid recent value and Approved to a non-null value
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
|
||||
authRequest.Approved = true;
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
var device = new Device
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Identifier = "test_identifier",
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IDeviceRepository>()
|
||||
.GetByIdentifierAsync(device.Identifier, authRequest.UserId)
|
||||
.Returns(device);
|
||||
|
||||
var updateModel = new AuthRequestUpdateRequestModel
|
||||
{
|
||||
Key = "test_key",
|
||||
@ -390,4 +626,69 @@ public class AuthRequestServiceTests
|
||||
async () => await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: An admin approves a request for one of their org users. For auditing purposes we need to
|
||||
/// log an event that correlates the action for who the request was approved for. On approval we also need to
|
||||
/// push the notification to the user.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAuthRequestAsync_AdminApproved_LogsEvent(
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
AuthRequest authRequest,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
|
||||
authRequest.Type = AuthRequestType.AdminApproval;
|
||||
authRequest.OrganizationId = organizationUser.OrganizationId;
|
||||
authRequest.Approved = null;
|
||||
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequest.Id)
|
||||
.Returns(authRequest);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(authRequest.OrganizationId!.Value, authRequest.UserId)
|
||||
.Returns(organizationUser);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.PasswordlessAuth
|
||||
.Returns(new Settings.GlobalSettings.PasswordlessAuthSettings());
|
||||
|
||||
var updateModel = new AuthRequestUpdateRequestModel
|
||||
{
|
||||
Key = "test_key",
|
||||
RequestApproved = true,
|
||||
MasterPasswordHash = "my_hash",
|
||||
};
|
||||
|
||||
var updatedAuthRequest = await sutProvider.Sut.UpdateAuthRequestAsync(authRequest.Id, authRequest.UserId, updateModel);
|
||||
|
||||
Assert.Equal("my_hash", updatedAuthRequest.MasterPasswordHash);
|
||||
Assert.Equal("test_key", updatedAuthRequest.Key);
|
||||
Assert.True(updatedAuthRequest.Approved);
|
||||
Assert.NotNull(updatedAuthRequest.ResponseDate);
|
||||
AssertHelper.AssertRecent(updatedAuthRequest.ResponseDate!.Value);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogOrganizationUserEventAsync(
|
||||
Arg.Is(organizationUser), Arg.Is(EventType.OrganizationUser_ApprovedAuthRequest));
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(1)
|
||||
.PushAuthRequestResponseAsync(authRequest);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAuthRequestAsync_BadId_ThrowsNotFound(
|
||||
SutProvider<AuthRequestService> sutProvider,
|
||||
Guid authRequestId)
|
||||
{
|
||||
sutProvider.GetDependency<IAuthRequestRepository>()
|
||||
.GetByIdAsync(authRequestId)
|
||||
.Returns((AuthRequest?)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAuthRequestAsync(
|
||||
authRequestId, Guid.NewGuid(), new AuthRequestUpdateRequestModel()));
|
||||
}
|
||||
}
|
||||
|
@ -20,11 +20,11 @@ public class OrganizationCustomization : ICustomization
|
||||
public void Customize(IFixture fixture)
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var maxConnections = (short)new Random().Next(10, short.MaxValue);
|
||||
var maxCollections = (short)new Random().Next(10, short.MaxValue);
|
||||
|
||||
fixture.Customize<Organization>(composer => composer
|
||||
.With(o => o.Id, organizationId)
|
||||
.With(o => o.MaxCollections, maxConnections)
|
||||
.With(o => o.MaxCollections, maxCollections)
|
||||
.With(o => o.UseGroups, UseGroups));
|
||||
|
||||
fixture.Customize<Collection>(composer =>
|
||||
@ -127,6 +127,24 @@ internal class OrganizationInvite : ICustomization
|
||||
}
|
||||
}
|
||||
|
||||
public class SecretsManagerOrganizationCustomization : ICustomization
|
||||
{
|
||||
public void Customize(IFixture fixture)
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var planType = PlanType.EnterpriseAnnually;
|
||||
|
||||
fixture.Customize<Organization>(composer => composer
|
||||
.With(o => o.Id, organizationId)
|
||||
.With(o => o.UseSecretsManager, true)
|
||||
.With(o => o.PlanType, planType)
|
||||
.With(o => o.Plan, StaticStore.GetPasswordManagerPlan(planType).Name)
|
||||
.With(o => o.MaxAutoscaleSmSeats, (int?)null)
|
||||
.With(o => o.MaxAutoscaleSmServiceAccounts, (int?)null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
internal class OrganizationCustomizeAttribute : BitCustomizeAttribute
|
||||
{
|
||||
public bool UseGroups { get; set; }
|
||||
@ -162,3 +180,9 @@ internal class OrganizationInviteCustomizeAttribute : BitCustomizeAttribute
|
||||
PermissionsBlob = PermissionsBlob,
|
||||
};
|
||||
}
|
||||
|
||||
internal class SecretsManagerOrganizationCustomizeAttribute : BitCustomizeAttribute
|
||||
{
|
||||
public override ICustomization GetCustomization() =>
|
||||
new SecretsManagerOrganizationCustomization();
|
||||
}
|
||||
|
@ -0,0 +1,31 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Business;
|
||||
|
||||
[SecretsManagerOrganizationCustomize]
|
||||
public class SecretsManagerSubscriptionUpdateTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.Custom)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
public async Task UpdateSubscriptionAsync_WithNonSecretsManagerPlanType_ThrowsBadRequestException(
|
||||
PlanType planType,
|
||||
Organization organization)
|
||||
{
|
||||
organization.PlanType = planType;
|
||||
|
||||
var exception = Assert.Throws<NotFoundException>(() => new SecretsManagerSubscriptionUpdate(organization, false));
|
||||
Assert.Contains("Invalid Secrets Manager plan", exception.Message, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,18 @@
|
||||
using Bit.Core.Entities;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class DeviceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
@ -33,4 +39,227 @@ public class DeviceServiceTests
|
||||
await pushRepo.Received().CreateOrUpdateRegistrationAsync("testtoken", id.ToString(),
|
||||
userId.ToString(), "testid", DeviceType.Android);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: A user choosed to keep trust in one of their current trusted devices, but not in another one of their
|
||||
/// devices. We will rotate the trust of the currently signed in device as well as the device they chose but will
|
||||
/// remove the trust of the device they didn't give new keys for.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateDevicesTrustAsync_Works(
|
||||
SutProvider<DeviceService> sutProvider,
|
||||
Guid currentUserId,
|
||||
Device deviceOne,
|
||||
Device deviceTwo,
|
||||
Device deviceThree)
|
||||
{
|
||||
SetupOldTrust(deviceOne);
|
||||
SetupOldTrust(deviceTwo);
|
||||
SetupOldTrust(deviceThree);
|
||||
|
||||
deviceOne.Identifier = "current_device";
|
||||
|
||||
sutProvider.GetDependency<IDeviceRepository>()
|
||||
.GetManyByUserIdAsync(currentUserId)
|
||||
.Returns(new List<Device>
|
||||
{
|
||||
deviceOne,
|
||||
deviceTwo,
|
||||
deviceThree,
|
||||
});
|
||||
|
||||
var currentDeviceModel = new DeviceKeysUpdateRequestModel
|
||||
{
|
||||
EncryptedPublicKey = "current_encrypted_public_key",
|
||||
EncryptedUserKey = "current_encrypted_user_key",
|
||||
};
|
||||
|
||||
var alteredDeviceModels = new List<OtherDeviceKeysUpdateRequestModel>
|
||||
{
|
||||
new OtherDeviceKeysUpdateRequestModel
|
||||
{
|
||||
DeviceId = deviceTwo.Id,
|
||||
EncryptedPublicKey = "encrypted_public_key_two",
|
||||
EncryptedUserKey = "encrypted_user_key_two",
|
||||
},
|
||||
};
|
||||
|
||||
await sutProvider.Sut.UpdateDevicesTrustAsync("current_device", currentUserId, currentDeviceModel, alteredDeviceModels);
|
||||
|
||||
// Updating trust, "current" or "other" only needs to change the EncryptedPublicKey & EncryptedUserKey
|
||||
await sutProvider.GetDependency<IDeviceRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Device>(d =>
|
||||
d.Id == deviceOne.Id &&
|
||||
d.EncryptedPublicKey == "current_encrypted_public_key" &&
|
||||
d.EncryptedUserKey == "current_encrypted_user_key" &&
|
||||
d.EncryptedPrivateKey == "old_private_deviceOne"));
|
||||
|
||||
await sutProvider.GetDependency<IDeviceRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Device>(d =>
|
||||
d.Id == deviceTwo.Id &&
|
||||
d.EncryptedPublicKey == "encrypted_public_key_two" &&
|
||||
d.EncryptedUserKey == "encrypted_user_key_two" &&
|
||||
d.EncryptedPrivateKey == "old_private_deviceTwo"));
|
||||
|
||||
// Clearing trust should remove all key values
|
||||
await sutProvider.GetDependency<IDeviceRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Device>(d =>
|
||||
d.Id == deviceThree.Id &&
|
||||
d.EncryptedPublicKey == null &&
|
||||
d.EncryptedUserKey == null &&
|
||||
d.EncryptedPrivateKey == null));
|
||||
|
||||
// Should have recieved a total of 3 calls, the ones asserted above
|
||||
await sutProvider.GetDependency<IDeviceRepository>()
|
||||
.Received(3)
|
||||
.UpsertAsync(Arg.Any<Device>());
|
||||
|
||||
// TODO: .NET 8: Use nameof for parameter name.
|
||||
static void SetupOldTrust(Device device, [CallerArgumentExpression("device")] string expression = null)
|
||||
{
|
||||
device.EncryptedPublicKey = $"old_public_{expression}";
|
||||
device.EncryptedPrivateKey = $"old_private_{expression}";
|
||||
device.EncryptedUserKey = $"old_user_{expression}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: This could result from a poor implementation of this method, if they attempt add trust to a device
|
||||
/// that doesn't already have trust. They would have to create brand new values and for that values to be accurate
|
||||
/// they would technically have all the values needed to trust a device, that is why we don't consider this bad
|
||||
/// enough to throw but do skip it because we'd rather keep number of ways for trust to be added to the endpoint we
|
||||
/// already have.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateDevicesTrustAsync_DoesNotUpdateUntrustedDevices(
|
||||
SutProvider<DeviceService> sutProvider,
|
||||
Guid currentUserId,
|
||||
Device deviceOne,
|
||||
Device deviceTwo)
|
||||
{
|
||||
deviceOne.Identifier = "current_device";
|
||||
|
||||
// Make deviceTwo untrusted
|
||||
deviceTwo.EncryptedUserKey = string.Empty;
|
||||
deviceTwo.EncryptedPublicKey = string.Empty;
|
||||
deviceTwo.EncryptedPrivateKey = string.Empty;
|
||||
|
||||
sutProvider.GetDependency<IDeviceRepository>()
|
||||
.GetManyByUserIdAsync(currentUserId)
|
||||
.Returns(new List<Device>
|
||||
{
|
||||
deviceOne,
|
||||
deviceTwo,
|
||||
});
|
||||
|
||||
var currentDeviceModel = new DeviceKeysUpdateRequestModel
|
||||
{
|
||||
EncryptedPublicKey = "current_encrypted_public_key",
|
||||
EncryptedUserKey = "current_encrypted_user_key",
|
||||
};
|
||||
|
||||
var alteredDeviceModels = new List<OtherDeviceKeysUpdateRequestModel>
|
||||
{
|
||||
new OtherDeviceKeysUpdateRequestModel
|
||||
{
|
||||
DeviceId = deviceTwo.Id,
|
||||
EncryptedPublicKey = "encrypted_public_key_two",
|
||||
EncryptedUserKey = "encrypted_user_key_two",
|
||||
},
|
||||
};
|
||||
|
||||
await sutProvider.Sut.UpdateDevicesTrustAsync("current_device", currentUserId, currentDeviceModel, alteredDeviceModels);
|
||||
|
||||
// Check that UpsertAsync was called for the trusted device
|
||||
await sutProvider.GetDependency<IDeviceRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Device>(d =>
|
||||
d.Id == deviceOne.Id &&
|
||||
d.EncryptedPublicKey == "current_encrypted_public_key" &&
|
||||
d.EncryptedUserKey == "current_encrypted_user_key"));
|
||||
|
||||
// Check that UpsertAsync was not called for the untrusted device
|
||||
await sutProvider.GetDependency<IDeviceRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertAsync(Arg.Is<Device>(d => d.Id == deviceTwo.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: This should only happen if someone were to take the access token from a different device and try to rotate
|
||||
/// a device that they don't actually have.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateDevicesTrustAsync_ThrowsNotFoundException_WhenCurrentDeviceIdentifierDoesNotExist(
|
||||
SutProvider<DeviceService> sutProvider,
|
||||
Guid currentUserId,
|
||||
Device deviceOne,
|
||||
Device deviceTwo)
|
||||
{
|
||||
deviceOne.Identifier = "some_other_device";
|
||||
deviceTwo.Identifier = "another_device";
|
||||
|
||||
sutProvider.GetDependency<IDeviceRepository>()
|
||||
.GetManyByUserIdAsync(currentUserId)
|
||||
.Returns(new List<Device>
|
||||
{
|
||||
deviceOne,
|
||||
deviceTwo,
|
||||
});
|
||||
|
||||
var currentDeviceModel = new DeviceKeysUpdateRequestModel
|
||||
{
|
||||
EncryptedPublicKey = "current_encrypted_public_key",
|
||||
EncryptedUserKey = "current_encrypted_user_key",
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.UpdateDevicesTrustAsync("current_device", currentUserId, currentDeviceModel,
|
||||
Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: This should only happen from a poorly implemented user of this method but important to enforce someone
|
||||
/// using the method correctly, a device should only be rotated intentionally and including it as both the current
|
||||
/// device and one of the users other device would mean they could rotate it twice and we aren't sure
|
||||
/// which one they would want to win out.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateDevicesTrustAsync_ThrowsBadRequestException_WhenCurrentDeviceIsIncludedInAlteredDevices(
|
||||
SutProvider<DeviceService> sutProvider,
|
||||
Guid currentUserId,
|
||||
Device deviceOne,
|
||||
Device deviceTwo)
|
||||
{
|
||||
deviceOne.Identifier = "current_device";
|
||||
|
||||
sutProvider.GetDependency<IDeviceRepository>()
|
||||
.GetManyByUserIdAsync(currentUserId)
|
||||
.Returns(new List<Device>
|
||||
{
|
||||
deviceOne,
|
||||
deviceTwo,
|
||||
});
|
||||
|
||||
var currentDeviceModel = new DeviceKeysUpdateRequestModel
|
||||
{
|
||||
EncryptedPublicKey = "current_encrypted_public_key",
|
||||
EncryptedUserKey = "current_encrypted_user_key",
|
||||
};
|
||||
|
||||
var alteredDeviceModels = new List<OtherDeviceKeysUpdateRequestModel>
|
||||
{
|
||||
new OtherDeviceKeysUpdateRequestModel
|
||||
{
|
||||
DeviceId = deviceOne.Id, // current device is included in alteredDevices
|
||||
EncryptedPublicKey = "encrypted_public_key_one",
|
||||
EncryptedUserKey = "encrypted_user_key_one",
|
||||
},
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.UpdateDevicesTrustAsync("current_device", currentUserId, currentDeviceModel, alteredDeviceModels));
|
||||
}
|
||||
}
|
||||
|
@ -406,7 +406,7 @@ public class PolicyServiceTests
|
||||
[BitAutoData(true, false)]
|
||||
[BitAutoData(false, true)]
|
||||
[BitAutoData(false, false)]
|
||||
public async Task SaveAsync_PolicyRequiredByTrustedDeviceEncryption_DisablePolicyOrDisableAutomaticEnrollment_ThrowsBadRequest(
|
||||
public async Task SaveAsync_ResetPasswordPolicyRequiredByTrustedDeviceEncryption_DisablePolicyOrDisableAutomaticEnrollment_ThrowsBadRequest(
|
||||
bool policyEnabled,
|
||||
bool autoEnrollEnabled,
|
||||
[PolicyFixtures.Policy(PolicyType.ResetPassword)] Policy policy,
|
||||
@ -448,6 +448,43 @@ public class PolicyServiceTests
|
||||
.LogPolicyEventAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_RequireSsoPolicyRequiredByTrustedDeviceEncryption_DisablePolicy_ThrowsBadRequest(
|
||||
[PolicyFixtures.Policy(PolicyType.RequireSso)] Policy policy,
|
||||
SutProvider<PolicyService> sutProvider)
|
||||
{
|
||||
policy.Enabled = false;
|
||||
|
||||
SetupOrg(sutProvider, policy.OrganizationId, new Organization
|
||||
{
|
||||
Id = policy.OrganizationId,
|
||||
UsePolicies = true,
|
||||
});
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = true };
|
||||
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption });
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policy.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(policy,
|
||||
Substitute.For<IUserService>(),
|
||||
Substitute.For<IOrganizationService>(),
|
||||
Guid.NewGuid()));
|
||||
|
||||
Assert.Contains("Trusted device encryption is on and requires this policy.", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertAsync(default);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.LogPolicyEventAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_PolicyRequiredForAccountRecovery_NotEnabled_ThrowsBadRequestAsync(
|
||||
[PolicyFixtures.Policy(Enums.PolicyType.ResetPassword)] Policy policy, SutProvider<PolicyService> sutProvider)
|
||||
@ -624,12 +661,18 @@ public class PolicyServiceTests
|
||||
private static void SetupUserPolicies(Guid userId, SutProvider<PolicyService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByUserIdWithPolicyDetailsAsync(userId)
|
||||
.GetByUserIdWithPolicyDetailsAsync(userId, PolicyType.RequireSso)
|
||||
.Returns(new List<OrganizationUserPolicyDetails>
|
||||
{
|
||||
new() { OrganizationId = Guid.NewGuid(), PolicyType = PolicyType.RequireSso, PolicyEnabled = false, OrganizationUserType = OrganizationUserType.Owner, OrganizationUserStatus = OrganizationUserStatusType.Confirmed, IsProvider = false},
|
||||
new() { OrganizationId = Guid.NewGuid(), PolicyType = PolicyType.RequireSso, PolicyEnabled = true, OrganizationUserType = OrganizationUserType.Owner, OrganizationUserStatus = OrganizationUserStatusType.Confirmed, IsProvider = false },
|
||||
new() { OrganizationId = Guid.NewGuid(), PolicyType = PolicyType.RequireSso, PolicyEnabled = true, OrganizationUserType = OrganizationUserType.Owner, OrganizationUserStatus = OrganizationUserStatusType.Confirmed, IsProvider = true },
|
||||
new() { OrganizationId = Guid.NewGuid(), PolicyType = PolicyType.RequireSso, PolicyEnabled = true, OrganizationUserType = OrganizationUserType.Owner, OrganizationUserStatus = OrganizationUserStatusType.Confirmed, IsProvider = true }
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByUserIdWithPolicyDetailsAsync(userId, PolicyType.DisableSend)
|
||||
.Returns(new List<OrganizationUserPolicyDetails>
|
||||
{
|
||||
new() { OrganizationId = Guid.NewGuid(), PolicyType = PolicyType.DisableSend, PolicyEnabled = true, OrganizationUserType = OrganizationUserType.User, OrganizationUserStatus = OrganizationUserStatusType.Invited, IsProvider = false },
|
||||
new() { OrganizationId = Guid.NewGuid(), PolicyType = PolicyType.DisableSend, PolicyEnabled = true, OrganizationUserType = OrganizationUserType.User, OrganizationUserStatus = OrganizationUserStatusType.Invited, IsProvider = true }
|
||||
});
|
||||
|
@ -1,15 +1,23 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Fido2NetLib;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReceivedExtensions;
|
||||
using Xunit;
|
||||
@ -134,7 +142,7 @@ public class UserServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void HasPremiumFromOrganization_Returns_False_If_No_Orgs(SutProvider<UserService> sutProvider, User user)
|
||||
public async Task HasPremiumFromOrganization_Returns_False_If_No_Orgs(SutProvider<UserService> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(user.Id).Returns(new List<OrganizationUser>());
|
||||
Assert.False(await sutProvider.Sut.HasPremiumFromOrganization(user));
|
||||
@ -144,7 +152,7 @@ public class UserServiceTests
|
||||
[Theory]
|
||||
[BitAutoData(false, true)]
|
||||
[BitAutoData(true, false)]
|
||||
public async void HasPremiumFromOrganization_Returns_False_If_Org_Not_Eligible(bool orgEnabled, bool orgUsersGetPremium, SutProvider<UserService> sutProvider, User user, OrganizationUser orgUser, Organization organization)
|
||||
public async Task HasPremiumFromOrganization_Returns_False_If_Org_Not_Eligible(bool orgEnabled, bool orgUsersGetPremium, SutProvider<UserService> sutProvider, User user, OrganizationUser orgUser, Organization organization)
|
||||
{
|
||||
orgUser.OrganizationId = organization.Id;
|
||||
organization.Enabled = orgEnabled;
|
||||
@ -158,7 +166,7 @@ public class UserServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void HasPremiumFromOrganization_Returns_True_If_Org_Eligible(SutProvider<UserService> sutProvider, User user, OrganizationUser orgUser, Organization organization)
|
||||
public async Task HasPremiumFromOrganization_Returns_True_If_Org_Eligible(SutProvider<UserService> sutProvider, User user, OrganizationUser orgUser, Organization organization)
|
||||
{
|
||||
orgUser.OrganizationId = organization.Id;
|
||||
organization.Enabled = true;
|
||||
@ -170,4 +178,145 @@ public class UserServiceTests
|
||||
|
||||
Assert.True(await sutProvider.Sut.HasPremiumFromOrganization(user));
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum ShouldCheck
|
||||
{
|
||||
Password = 0x1,
|
||||
OTP = 0x2,
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// A user who has a password, and the password is valid should only check for that password
|
||||
[BitAutoData(true, "test_password", true, ShouldCheck.Password)]
|
||||
// A user who does not have a password, should only check if the OTP is valid
|
||||
[BitAutoData(false, "otp_token", true, ShouldCheck.OTP)]
|
||||
// A user who has a password but supplied a OTP, it will check password first and then try OTP
|
||||
[BitAutoData(true, "otp_token", true, ShouldCheck.Password | ShouldCheck.OTP)]
|
||||
// A user who does not have a password and supplied an invalid OTP token, should only check OTP and return invalid
|
||||
[BitAutoData(false, "bad_otp_token", false, ShouldCheck.OTP)]
|
||||
// A user who does have a password but they supply a bad one, we will check both but it will still be invalid
|
||||
[BitAutoData(true, "bad_test_password", false, ShouldCheck.Password | ShouldCheck.OTP)]
|
||||
public async Task VerifySecretAsync_Works(
|
||||
bool shouldHavePassword, string secret, bool expectedIsVerified, ShouldCheck shouldCheck, // inline theory data
|
||||
SutProvider<UserService> sutProvider, User user) // AutoFixture injected data
|
||||
{
|
||||
// Arrange
|
||||
var tokenProvider = SetupFakeTokenProvider(sutProvider, user);
|
||||
SetupUserAndDevice(user, shouldHavePassword);
|
||||
|
||||
// Setup the fake password verification
|
||||
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
||||
substitutedUserPasswordStore
|
||||
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
||||
.Returns((ci) =>
|
||||
{
|
||||
return Task.FromResult("hashed_test_password");
|
||||
});
|
||||
|
||||
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore, "store");
|
||||
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>("passwordHasher")
|
||||
.VerifyHashedPassword(user, "hashed_test_password", "test_password")
|
||||
.Returns((ci) =>
|
||||
{
|
||||
return PasswordVerificationResult.Success;
|
||||
});
|
||||
|
||||
// HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured
|
||||
var sut = new UserService(
|
||||
sutProvider.GetDependency<IUserRepository>(),
|
||||
sutProvider.GetDependency<ICipherRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationRepository>(),
|
||||
sutProvider.GetDependency<IMailService>(),
|
||||
sutProvider.GetDependency<IPushNotificationService>(),
|
||||
sutProvider.GetDependency<IUserStore<User>>(),
|
||||
sutProvider.GetDependency<IOptions<IdentityOptions>>(),
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>(),
|
||||
sutProvider.GetDependency<IEnumerable<IUserValidator<User>>>(),
|
||||
sutProvider.GetDependency<IEnumerable<IPasswordValidator<User>>>(),
|
||||
sutProvider.GetDependency<ILookupNormalizer>(),
|
||||
sutProvider.GetDependency<IdentityErrorDescriber>(),
|
||||
sutProvider.GetDependency<IServiceProvider>(),
|
||||
sutProvider.GetDependency<ILogger<UserManager<User>>>(),
|
||||
sutProvider.GetDependency<ILicensingService>(),
|
||||
sutProvider.GetDependency<IEventService>(),
|
||||
sutProvider.GetDependency<IApplicationCacheService>(),
|
||||
sutProvider.GetDependency<IDataProtectionProvider>(),
|
||||
sutProvider.GetDependency<IPaymentService>(),
|
||||
sutProvider.GetDependency<IPolicyRepository>(),
|
||||
sutProvider.GetDependency<IPolicyService>(),
|
||||
sutProvider.GetDependency<IReferenceEventService>(),
|
||||
sutProvider.GetDependency<IFido2>(),
|
||||
sutProvider.GetDependency<ICurrentContext>(),
|
||||
sutProvider.GetDependency<IGlobalSettings>(),
|
||||
sutProvider.GetDependency<IOrganizationService>(),
|
||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
||||
sutProvider.GetDependency<IStripeSyncService>());
|
||||
|
||||
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
|
||||
|
||||
Assert.Equal(expectedIsVerified, actualIsVerified);
|
||||
|
||||
await tokenProvider
|
||||
.Received(shouldCheck.HasFlag(ShouldCheck.OTP) ? 1 : 0)
|
||||
.ValidateAsync(Arg.Any<string>(), secret, Arg.Any<UserManager<User>>(), user);
|
||||
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>()
|
||||
.Received(shouldCheck.HasFlag(ShouldCheck.Password) ? 1 : 0)
|
||||
.VerifyHashedPassword(user, "hashed_test_password", secret);
|
||||
}
|
||||
|
||||
private static void SetupUserAndDevice(User user,
|
||||
bool shouldHavePassword)
|
||||
{
|
||||
if (shouldHavePassword)
|
||||
{
|
||||
user.MasterPassword = "test_password";
|
||||
}
|
||||
else
|
||||
{
|
||||
user.MasterPassword = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IUserTwoFactorTokenProvider<User> SetupFakeTokenProvider(SutProvider<UserService> sutProvider, User user)
|
||||
{
|
||||
var fakeUserTwoFactorProvider = Substitute.For<IUserTwoFactorTokenProvider<User>>();
|
||||
|
||||
fakeUserTwoFactorProvider
|
||||
.GenerateAsync(Arg.Any<string>(), Arg.Any<UserManager<User>>(), user)
|
||||
.Returns("OTP_TOKEN");
|
||||
|
||||
fakeUserTwoFactorProvider
|
||||
.ValidateAsync(Arg.Any<string>(), Arg.Is<string>(s => s != "otp_token"), Arg.Any<UserManager<User>>(), user)
|
||||
.Returns(false);
|
||||
|
||||
fakeUserTwoFactorProvider
|
||||
.ValidateAsync(Arg.Any<string>(), "otp_token", Arg.Any<UserManager<User>>(), user)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOptions<IdentityOptions>>()
|
||||
.Value.Returns(new IdentityOptions
|
||||
{
|
||||
Tokens = new TokenOptions
|
||||
{
|
||||
ProviderMap = new Dictionary<string, TokenProviderDescriptor>()
|
||||
{
|
||||
["Email"] = new TokenProviderDescriptor(typeof(IUserTwoFactorTokenProvider<User>))
|
||||
{
|
||||
ProviderInstance = fakeUserTwoFactorProvider,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The above arranging of dependencies is used in the constructor of UserManager
|
||||
// ref: https://github.com/dotnet/aspnetcore/blob/bfeb3bf9005c36b081d1e48725531ee0e15a9dfb/src/Identity/Extensions.Core/src/UserManager.cs#L103-L120
|
||||
// since the constructor of the Sut has ran already (when injected) I need to recreate it to get it to run again
|
||||
sutProvider.Create();
|
||||
|
||||
return fakeUserTwoFactorProvider;
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
@ -33,33 +34,10 @@ public class IdentityServerSsoTests
|
||||
public async Task Test_MasterPassword_DecryptionType()
|
||||
{
|
||||
// Arrange
|
||||
var challenge = new string('c', 50);
|
||||
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.MasterPassword,
|
||||
}, challenge);
|
||||
|
||||
// Act
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", "10" },
|
||||
{ "deviceIdentifier", "test_id" },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "twoFactorToken", "TEST"},
|
||||
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||
{ "twoFactorRemember", "0" },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", "test_code" },
|
||||
{ "code_verifier", challenge },
|
||||
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||
}));
|
||||
using var responseBody = await RunSuccessTestAsync(MemberDecryptionType.MasterPassword);
|
||||
|
||||
// Assert
|
||||
// If the organization has a member decryption type of MasterPassword that should be the only option in the reply
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||
@ -80,34 +58,11 @@ public class IdentityServerSsoTests
|
||||
public async Task SsoLogin_TrustedDeviceEncryption_ReturnsOptions()
|
||||
{
|
||||
// Arrange
|
||||
var challenge = new string('c', 50);
|
||||
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||
}, challenge);
|
||||
|
||||
// Act
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", "10" },
|
||||
{ "deviceIdentifier", "test_id" },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "twoFactorToken", "TEST"},
|
||||
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||
{ "twoFactorRemember", "0" },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", "test_code" },
|
||||
{ "code_verifier", challenge },
|
||||
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||
}));
|
||||
using var responseBody = await RunSuccessTestAsync(MemberDecryptionType.TrustedDeviceEncryption);
|
||||
|
||||
// Assert
|
||||
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||
// they can decrypt with either option
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||
@ -132,47 +87,25 @@ public class IdentityServerSsoTests
|
||||
public async Task SsoLogin_TrustedDeviceEncryption_WithAdminResetPolicy_ReturnsOptions()
|
||||
{
|
||||
// Arrange
|
||||
var challenge = new string('c', 50);
|
||||
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||
using var responseBody = await RunSuccessTestAsync(async factory =>
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||
}, challenge);
|
||||
var database = factory.GetDatabaseContext();
|
||||
|
||||
var database = factory.GetDatabaseContext();
|
||||
var organization = await database.Organizations.SingleAsync();
|
||||
|
||||
var organization = await database.Organizations.SingleAsync();
|
||||
var user = await database.Users.SingleAsync(u => u.Email == TestEmail);
|
||||
|
||||
var policyRepository = factory.Services.GetRequiredService<IPolicyRepository>();
|
||||
await policyRepository.CreateAsync(new Policy
|
||||
{
|
||||
Type = PolicyType.ResetPassword,
|
||||
Enabled = true,
|
||||
Data = "{\"autoEnrollEnabled\": false }",
|
||||
OrganizationId = organization.Id,
|
||||
});
|
||||
var organizationUser = await database.OrganizationUsers.SingleAsync(
|
||||
ou => ou.OrganizationId == organization.Id && ou.UserId == user.Id);
|
||||
|
||||
// Act
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", "10" },
|
||||
{ "deviceIdentifier", "test_id" },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "twoFactorToken", "TEST"},
|
||||
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||
{ "twoFactorRemember", "0" },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", "test_code" },
|
||||
{ "code_verifier", challenge },
|
||||
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||
}));
|
||||
organizationUser.ResetPasswordKey = "something";
|
||||
|
||||
await database.SaveChangesAsync();
|
||||
}, MemberDecryptionType.TrustedDeviceEncryption);
|
||||
|
||||
// Assert
|
||||
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||
// they can decrypt with either option
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
|
||||
@ -183,7 +116,8 @@ public class IdentityServerSsoTests
|
||||
// "Object": "userDecryptionOptions"
|
||||
// "HasMasterPassword": true,
|
||||
// "TrustedDeviceOption": {
|
||||
// "HasAdminApproval": true
|
||||
// "HasAdminApproval": true,
|
||||
// "HasManageResetPasswordPermission": false
|
||||
// }
|
||||
// }
|
||||
|
||||
@ -196,6 +130,126 @@ public class IdentityServerSsoTests
|
||||
|
||||
[Fact]
|
||||
public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_ReturnsOneOption()
|
||||
{
|
||||
using var responseBody = await RunSuccessTestAsync(async factory =>
|
||||
{
|
||||
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||
|
||||
}, MemberDecryptionType.TrustedDeviceEncryption);
|
||||
|
||||
// Assert
|
||||
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||
// they can decrypt with either option
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||
|
||||
// Expected to look like:
|
||||
// "UserDecryptionOptions": {
|
||||
// "Object": "userDecryptionOptions"
|
||||
// "HasMasterPassword": false,
|
||||
// "TrustedDeviceOption": {
|
||||
// "HasAdminApproval": true,
|
||||
// "HasLoginApprovingDevice": false,
|
||||
// "HasManageResetPasswordPermission": false
|
||||
// }
|
||||
// }
|
||||
|
||||
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
|
||||
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False);
|
||||
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasManageResetPasswordPermission", JsonValueKind.False);
|
||||
|
||||
// This asserts that device keys are not coming back in the response because this should be a new device.
|
||||
// if we ever add new properties that come back from here it is fine to change the expected number of properties
|
||||
// but it should still be asserted in some way that keys are not amongst them.
|
||||
Assert.Collection(trustedDeviceOption.EnumerateObject(),
|
||||
p =>
|
||||
{
|
||||
Assert.Equal("HasAdminApproval", p.Name);
|
||||
Assert.Equal(JsonValueKind.False, p.Value.ValueKind);
|
||||
},
|
||||
p =>
|
||||
{
|
||||
Assert.Equal("HasLoginApprovingDevice", p.Name);
|
||||
Assert.Equal(JsonValueKind.False, p.Value.ValueKind);
|
||||
},
|
||||
p =>
|
||||
{
|
||||
Assert.Equal("HasManageResetPasswordPermission", p.Name);
|
||||
Assert.Equal(JsonValueKind.False, p.Value.ValueKind);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If a user has a device that is able to accept login with device requests, we should return that state
|
||||
/// with the user decryption options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_HasLoginApprovingDevice_ReturnsTrue()
|
||||
{
|
||||
using var responseBody = await RunSuccessTestAsync(async factory =>
|
||||
{
|
||||
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||
var userRepository = factory.Services.GetRequiredService<IUserRepository>();
|
||||
var user = await userRepository.GetByEmailAsync(TestEmail);
|
||||
|
||||
var deviceRepository = factory.Services.GetRequiredService<IDeviceRepository>();
|
||||
await deviceRepository.CreateAsync(new Device
|
||||
{
|
||||
Identifier = "my_other_device",
|
||||
Type = DeviceType.Android,
|
||||
Name = "Android",
|
||||
UserId = user.Id,
|
||||
});
|
||||
}, MemberDecryptionType.TrustedDeviceEncryption);
|
||||
|
||||
// Assert
|
||||
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||
// they can decrypt with either option
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||
|
||||
// Expected to look like:
|
||||
// "UserDecryptionOptions": {
|
||||
// "Object": "userDecryptionOptions"
|
||||
// "HasMasterPassword": false,
|
||||
// "TrustedDeviceOption": {
|
||||
// "HasAdminApproval": true,
|
||||
// "HasLoginApprovingDevice": true,
|
||||
// "HasManageResetPasswordPermission": false
|
||||
// }
|
||||
// }
|
||||
|
||||
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
|
||||
|
||||
// This asserts that device keys are not coming back in the response because this should be a new device.
|
||||
// if we ever add new properties that come back from here it is fine to change the expected number of properties
|
||||
// but it should still be asserted in some way that keys are not amongst them.
|
||||
Assert.Collection(trustedDeviceOption.EnumerateObject(),
|
||||
p =>
|
||||
{
|
||||
Assert.Equal("HasAdminApproval", p.Name);
|
||||
Assert.Equal(JsonValueKind.False, p.Value.ValueKind);
|
||||
},
|
||||
p =>
|
||||
{
|
||||
Assert.Equal("HasLoginApprovingDevice", p.Name);
|
||||
Assert.Equal(JsonValueKind.True, p.Value.ValueKind);
|
||||
},
|
||||
p =>
|
||||
{
|
||||
Assert.Equal("HasManageResetPasswordPermission", p.Name);
|
||||
Assert.Equal(JsonValueKind.False, p.Value.ValueKind);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story: When a user signs in with SSO on a device they have already signed in with we need to return the keys
|
||||
/// back to them for the current device if it has been trusted before.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_DeviceAlreadyTrusted_ReturnsOneOption()
|
||||
{
|
||||
// Arrange
|
||||
var challenge = new string('c', 50);
|
||||
@ -206,13 +260,33 @@ public class IdentityServerSsoTests
|
||||
|
||||
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||
|
||||
var deviceRepository = factory.Services.GetRequiredService<IDeviceRepository>();
|
||||
|
||||
var deviceIdentifier = $"test_id_{Guid.NewGuid()}";
|
||||
|
||||
var user = await factory.Services.GetRequiredService<IUserRepository>().GetByEmailAsync(TestEmail);
|
||||
|
||||
const string expectedPrivateKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==";
|
||||
const string expectedUserKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==";
|
||||
|
||||
var device = await deviceRepository.CreateAsync(new Device
|
||||
{
|
||||
Type = DeviceType.FirefoxBrowser,
|
||||
Identifier = deviceIdentifier,
|
||||
Name = "Thing",
|
||||
UserId = user.Id,
|
||||
EncryptedPrivateKey = expectedPrivateKey,
|
||||
EncryptedPublicKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==",
|
||||
EncryptedUserKey = expectedUserKey,
|
||||
});
|
||||
|
||||
// Act
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", "10" },
|
||||
{ "deviceIdentifier", "test_id" },
|
||||
{ "deviceIdentifier", deviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "twoFactorToken", "TEST"},
|
||||
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||
@ -237,30 +311,43 @@ public class IdentityServerSsoTests
|
||||
// "Object": "userDecryptionOptions"
|
||||
// "HasMasterPassword": false,
|
||||
// "TrustedDeviceOption": {
|
||||
// "HasAdminApproval": true
|
||||
// "HasAdminApproval": true,
|
||||
// "HasManageResetPasswordPermission": false,
|
||||
// "EncryptedPrivateKey": "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==",
|
||||
// "EncryptedUserKey": "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="
|
||||
// }
|
||||
// }
|
||||
|
||||
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
|
||||
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False);
|
||||
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasManageResetPasswordPermission", JsonValueKind.False);
|
||||
|
||||
var actualPrivateKey = AssertHelper.AssertJsonProperty(trustedDeviceOption, "EncryptedPrivateKey", JsonValueKind.String).GetString();
|
||||
Assert.Equal(expectedPrivateKey, actualPrivateKey);
|
||||
var actualUserKey = AssertHelper.AssertJsonProperty(trustedDeviceOption, "EncryptedUserKey", JsonValueKind.String).GetString();
|
||||
Assert.Equal(expectedUserKey, actualUserKey);
|
||||
}
|
||||
|
||||
// we should add a test case for JIT provisioned users. They don't have any orgs which caused
|
||||
// an error in the UserHasManageResetPasswordPermission set logic.
|
||||
|
||||
/// <summary>
|
||||
/// Story: When a user with TDE and the manage reset password permission signs in with SSO, we should return
|
||||
/// TrustedDeviceEncryption.HasManageResetPasswordPermission as true
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SsoLogin_TrustedDeviceEncryption_FlagTurnedOff_DoesNotReturnOption()
|
||||
public async Task SsoLogin_TrustedDeviceEncryption_UserHasManageResetPasswordPermission_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var challenge = new string('c', 50);
|
||||
|
||||
// This creates SsoConfig that HAS enabled trusted device encryption which should have only been
|
||||
// done with the feature flag turned on but we are testing that even if they have done that, this will turn off
|
||||
// if returning as an option if the flag has later been turned off. We should be very careful turning the flag
|
||||
// back off.
|
||||
// create user permissions with the ManageResetPassword permission
|
||||
var permissionsWithManageResetPassword = new Permissions() { ManageResetPassword = true };
|
||||
|
||||
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||
}, challenge, trustedDeviceEnabled: false);
|
||||
|
||||
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||
}, challenge, permissions: permissionsWithManageResetPassword);
|
||||
|
||||
// Act
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
@ -286,6 +373,33 @@ public class IdentityServerSsoTests
|
||||
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
|
||||
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||
|
||||
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
|
||||
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False);
|
||||
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasManageResetPasswordPermission", JsonValueKind.True);
|
||||
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task SsoLogin_TrustedDeviceEncryption_FlagTurnedOff_DoesNotReturnOption()
|
||||
{
|
||||
// This creates SsoConfig that HAS enabled trusted device encryption which should have only been
|
||||
// done with the feature flag turned on but we are testing that even if they have done that, this will turn off
|
||||
// if returning as an option if the flag has later been turned off. We should be very careful turning the flag
|
||||
// back off.
|
||||
using var responseBody = await RunSuccessTestAsync(async factory =>
|
||||
{
|
||||
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||
}, MemberDecryptionType.TrustedDeviceEncryption, trustedDeviceEnabled: false);
|
||||
|
||||
// Assert
|
||||
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||
// they can decrypt with either option
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||
|
||||
// Expected to look like:
|
||||
@ -301,36 +415,11 @@ public class IdentityServerSsoTests
|
||||
[Fact]
|
||||
public async Task SsoLogin_KeyConnector_ReturnsOptions()
|
||||
{
|
||||
// Arrange
|
||||
var challenge = new string('c', 50);
|
||||
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||
using var responseBody = await RunSuccessTestAsync(async factory =>
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.KeyConnector,
|
||||
KeyConnectorUrl = "https://key_connector.com"
|
||||
}, challenge);
|
||||
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||
}, MemberDecryptionType.KeyConnector, "https://key_connector.com");
|
||||
|
||||
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||
|
||||
// Act
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", "10" },
|
||||
{ "deviceIdentifier", "test_id" },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "twoFactorToken", "TEST"},
|
||||
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||
{ "twoFactorRemember", "0" },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", "test_code" },
|
||||
{ "code_verifier", challenge },
|
||||
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = responseBody.RootElement;
|
||||
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||
|
||||
@ -354,7 +443,51 @@ public class IdentityServerSsoTests
|
||||
Assert.Equal("https://key_connector.com", keyConnectorUrl);
|
||||
}
|
||||
|
||||
private static async Task<IdentityApplicationFactory> CreateFactoryAsync(SsoConfigurationData ssoConfigurationData, string challenge, bool trustedDeviceEnabled = true)
|
||||
private static async Task<JsonDocument> RunSuccessTestAsync(MemberDecryptionType memberDecryptionType)
|
||||
{
|
||||
return await RunSuccessTestAsync(factory => Task.CompletedTask, memberDecryptionType);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> RunSuccessTestAsync(Func<IdentityApplicationFactory, Task> configureFactory,
|
||||
MemberDecryptionType memberDecryptionType,
|
||||
string? keyConnectorUrl = null,
|
||||
bool trustedDeviceEnabled = true)
|
||||
{
|
||||
var challenge = new string('c', 50);
|
||||
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = memberDecryptionType,
|
||||
KeyConnectorUrl = keyConnectorUrl,
|
||||
}, challenge, trustedDeviceEnabled);
|
||||
|
||||
await configureFactory(factory);
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", "10" },
|
||||
{ "deviceIdentifier", "test_id" },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "twoFactorToken", "TEST"},
|
||||
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||
{ "twoFactorRemember", "0" },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", "test_code" },
|
||||
{ "code_verifier", challenge },
|
||||
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||
}));
|
||||
|
||||
// Only calls that result in a 200 OK should call this helper
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
|
||||
return await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
}
|
||||
|
||||
private static async Task<IdentityApplicationFactory> CreateFactoryAsync(
|
||||
SsoConfigurationData ssoConfigurationData,
|
||||
string challenge,
|
||||
bool trustedDeviceEnabled = true,
|
||||
Permissions? permissions = null)
|
||||
{
|
||||
var factory = new IdentityApplicationFactory();
|
||||
|
||||
@ -367,7 +500,7 @@ public class IdentityServerSsoTests
|
||||
RedirectUri = "https://localhost:8080/sso-connector.html",
|
||||
RequestedScopes = new[] { "api", "offline_access" },
|
||||
CodeChallenge = challenge.Sha256(),
|
||||
CodeChallengeMethod = "plain", //
|
||||
CodeChallengeMethod = "plain", //
|
||||
Subject = null, // Temporarily set it to null
|
||||
};
|
||||
|
||||
@ -400,12 +533,17 @@ public class IdentityServerSsoTests
|
||||
});
|
||||
|
||||
var organizationUserRepository = factory.Services.GetRequiredService<IOrganizationUserRepository>();
|
||||
|
||||
var orgUserPermissions =
|
||||
(permissions == null) ? null : JsonSerializer.Serialize(permissions, JsonHelpers.CamelCase);
|
||||
|
||||
var organizationUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
UserId = user.Id,
|
||||
OrganizationId = organization.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
Permissions = orgUserPermissions
|
||||
});
|
||||
|
||||
var ssoConfigRepository = factory.Services.GetRequiredService<ISsoConfigRepository>();
|
||||
|
@ -274,7 +274,7 @@ public class OrganizationUserRepositoryTests
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await orgUserRepos[i].GetByUserIdWithPolicyDetailsAsync(savedUser.Id);
|
||||
var result = await orgUserRepos[i].GetByUserIdWithPolicyDetailsAsync(savedUser.Id, policy.Type);
|
||||
results.Add(result.FirstOrDefault());
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM bitwardenprod.azurecr.io/server:latest
|
||||
FROM bitwarden/server:latest
|
||||
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
|
||||
|
136
util/Migrator/DbScripts/2023-07-17_00_DeleteUserSproc.sql
Normal file
136
util/Migrator/DbScripts/2023-07-17_00_DeleteUserSproc.sql
Normal file
@ -0,0 +1,136 @@
|
||||
IF OBJECT_ID('[dbo].[User_DeleteById]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[User_DeleteById]
|
||||
END
|
||||
GO
|
||||
|
||||
SET ANSI_NULLS ON
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[User_DeleteById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
WITH
|
||||
RECOMPILE
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
DECLARE @BatchSize INT = 100
|
||||
|
||||
-- Delete ciphers
|
||||
WHILE @BatchSize > 0
|
||||
BEGIN
|
||||
BEGIN TRANSACTION User_DeleteById_Ciphers
|
||||
|
||||
DELETE TOP(@BatchSize)
|
||||
FROM
|
||||
[dbo].[Cipher]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
SET @BatchSize = @@ROWCOUNT
|
||||
|
||||
COMMIT TRANSACTION User_DeleteById_Ciphers
|
||||
END
|
||||
|
||||
BEGIN TRANSACTION User_DeleteById
|
||||
|
||||
-- Delete folders
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[Folder]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Delete AuthRequest, must be before Device
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[AuthRequest]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Delete devices
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[Device]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Delete collection users
|
||||
DELETE
|
||||
CU
|
||||
FROM
|
||||
[dbo].[CollectionUser] CU
|
||||
INNER JOIN
|
||||
[dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId]
|
||||
WHERE
|
||||
OU.[UserId] = @Id
|
||||
|
||||
-- Delete group users
|
||||
DELETE
|
||||
GU
|
||||
FROM
|
||||
[dbo].[GroupUser] GU
|
||||
INNER JOIN
|
||||
[dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId]
|
||||
WHERE
|
||||
OU.[UserId] = @Id
|
||||
|
||||
-- Delete AccessPolicy
|
||||
DELETE
|
||||
AP
|
||||
FROM
|
||||
[dbo].[AccessPolicy] AP
|
||||
INNER JOIN
|
||||
[dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Delete organization users
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[OrganizationUser]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Delete provider users
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[ProviderUser]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Delete SSO Users
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[SsoUser]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Delete Emergency Accesses
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[EmergencyAccess]
|
||||
WHERE
|
||||
[GrantorId] = @Id
|
||||
OR
|
||||
[GranteeId] = @Id
|
||||
|
||||
-- Delete Sends
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[Send]
|
||||
WHERE
|
||||
[UserId] = @Id
|
||||
|
||||
-- Finally, delete the user
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[User]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
|
||||
COMMIT TRANSACTION User_DeleteById
|
||||
END
|
||||
|
@ -0,0 +1,35 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByUserIdWithPolicyDetails]
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@PolicyType TINYINT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
SELECT
|
||||
OU.[Id] AS OrganizationUserId,
|
||||
P.[OrganizationId],
|
||||
P.[Type] AS PolicyType,
|
||||
P.[Enabled] AS PolicyEnabled,
|
||||
P.[Data] AS PolicyData,
|
||||
OU.[Type] AS OrganizationUserType,
|
||||
OU.[Status] AS OrganizationUserStatus,
|
||||
OU.[Permissions] AS OrganizationUserPermissionsData,
|
||||
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]
|
||||
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
|
||||
GO
|
Reference in New Issue
Block a user