mirror of
https://github.com/bitwarden/server
synced 2025-12-27 13:43:18 +00:00
Merge branch 'km/move-models' of github.com:bitwarden/server into km/move-models
This commit is contained in:
4
.github/ISSUE_TEMPLATE/bw-lite.yml
vendored
4
.github/ISSUE_TEMPLATE/bw-lite.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Bitwarden Lite Deployment Bug Report
|
||||
name: Bitwarden lite Deployment Bug Report
|
||||
description: File a bug report
|
||||
labels: [bug, bw-lite-deploy]
|
||||
body:
|
||||
@@ -74,7 +74,7 @@ body:
|
||||
id: epic-label
|
||||
attributes:
|
||||
label: Issue-Link
|
||||
description: Link to our pinned issue, tracking all Bitwarden Lite
|
||||
description: Link to our pinned issue, tracking all Bitwarden lite
|
||||
value: |
|
||||
https://github.com/bitwarden/server/issues/2480
|
||||
validations:
|
||||
|
||||
2
.github/renovate.json5
vendored
2
.github/renovate.json5
vendored
@@ -63,7 +63,6 @@
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||
"DuoUniversal",
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
@@ -137,6 +136,7 @@
|
||||
"AspNetCoreRateLimit",
|
||||
"AspNetCoreRateLimit.Redis",
|
||||
"Azure.Data.Tables",
|
||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||
"Azure.Messaging.EventGrid",
|
||||
"Azure.Messaging.ServiceBus",
|
||||
"Azure.Storage.Blobs",
|
||||
|
||||
51
.github/workflows/build.yml
vendored
51
.github/workflows/build.yml
vendored
@@ -185,13 +185,6 @@ jobs:
|
||||
- name: Log in to ACR - production subscription
|
||||
run: az acr login -n bitwardenprod
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
########## Generate image tag and build Docker image ##########
|
||||
- name: Generate Docker image tag
|
||||
id: tag
|
||||
@@ -250,8 +243,6 @@ jobs:
|
||||
linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.image-tags.outputs.tags }}
|
||||
secrets: |
|
||||
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
@@ -280,7 +271,7 @@ jobs:
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
@@ -479,20 +470,29 @@ jobs:
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Trigger Bitwarden Lite build
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: self-host
|
||||
|
||||
- name: Trigger Bitwarden lite build
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: 'bitwarden',
|
||||
@@ -520,20 +520,29 @@ jobs:
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: devops
|
||||
|
||||
- name: Trigger k8s deploy
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: 'bitwarden',
|
||||
|
||||
27
.github/workflows/test-database.yml
vendored
27
.github/workflows/test-database.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
docker compose --profile mssql --profile postgres --profile mysql up -d
|
||||
shell: pwsh
|
||||
|
||||
- name: Add MariaDB for Bitwarden Lite
|
||||
- name: Add MariaDB for Bitwarden lite
|
||||
# Use a different port than MySQL
|
||||
run: |
|
||||
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
# Default Sqlite
|
||||
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
||||
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
||||
# Bitwarden Lite MariaDB
|
||||
# Bitwarden lite MariaDB
|
||||
BW_TEST_DATABASES__4__TYPE: "MySql"
|
||||
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
@@ -262,3 +262,26 @@ jobs:
|
||||
working-directory: "dev"
|
||||
run: docker compose down
|
||||
shell: pwsh
|
||||
|
||||
validate-migration-naming:
|
||||
name: Validate new migration naming and order
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate new migrations for pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch origin main:main
|
||||
pwsh dev/verify_migrations.ps1 -BaseRef main
|
||||
shell: pwsh
|
||||
|
||||
- name: Validate new migrations for push
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1
|
||||
shell: pwsh
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.11.1</Version>
|
||||
<Version>2025.12.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
132
dev/verify_migrations.ps1
Normal file
132
dev/verify_migrations.ps1
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates that new database migration files follow naming conventions and chronological order.
|
||||
|
||||
.DESCRIPTION
|
||||
This script validates migration files in util/Migrator/DbScripts/ to ensure:
|
||||
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
|
||||
2. New migrations are chronologically ordered (filename sorts after existing migrations)
|
||||
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
|
||||
4. A 2-digit sequence number is included (e.g., _00, _01)
|
||||
|
||||
.PARAMETER BaseRef
|
||||
The base git reference to compare against (e.g., 'main', 'HEAD~1')
|
||||
|
||||
.PARAMETER CurrentRef
|
||||
The current git reference (defaults to 'HEAD')
|
||||
|
||||
.EXAMPLE
|
||||
# For pull requests - compare against main branch
|
||||
.\verify_migrations.ps1 -BaseRef main
|
||||
|
||||
.EXAMPLE
|
||||
# For pushes - compare against previous commit
|
||||
.\verify_migrations.ps1 -BaseRef HEAD~1
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaseRef,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$CurrentRef = "HEAD"
|
||||
)
|
||||
|
||||
# Use invariant culture for consistent string comparison
|
||||
[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture
|
||||
|
||||
$migrationPath = "util/Migrator/DbScripts"
|
||||
|
||||
# Get list of migrations from base reference
|
||||
try {
|
||||
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
|
||||
$baseMigrations = @()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
|
||||
$baseMigrations = @()
|
||||
}
|
||||
|
||||
# Get list of migrations from current reference
|
||||
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
|
||||
|
||||
# Find added migrations
|
||||
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||
|
||||
if ($addedMigrations.Count -eq 0) {
|
||||
Write-Host "No new migration files added."
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "New migration files detected:"
|
||||
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Get the last migration from base reference
|
||||
if ($baseMigrations.Count -eq 0) {
|
||||
Write-Host "No previous migrations found (initial commit?). Skipping validation."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||
Write-Host "Last migration in base reference: $lastBaseMigration"
|
||||
Write-Host ""
|
||||
|
||||
# Required format regex: YYYY-MM-DD_NN_Description.sql
|
||||
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
|
||||
|
||||
$validationFailed = $false
|
||||
|
||||
foreach ($migration in $addedMigrations) {
|
||||
$migrationName = Split-Path -Leaf $migration
|
||||
|
||||
# Validate NEW migration filename format
|
||||
if ($migrationName -notmatch $formatRegex) {
|
||||
Write-Host "ERROR: Migration '$migrationName' does not match required format"
|
||||
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " - YYYY: 4-digit year"
|
||||
Write-Host " - MM: 2-digit month with leading zero (01-12)"
|
||||
Write-Host " - DD: 2-digit day with leading zero (01-31)"
|
||||
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
|
||||
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
|
||||
$validationFailed = $true
|
||||
continue
|
||||
}
|
||||
|
||||
# Compare migration name with last base migration (using ordinal string comparison)
|
||||
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
|
||||
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
|
||||
$validationFailed = $true
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
if ($validationFailed) {
|
||||
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
|
||||
Write-Host ""
|
||||
Write-Host "All new migration files must:"
|
||||
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
|
||||
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
|
||||
Write-Host " 4. Have a filename that sorts after the last migration in base"
|
||||
Write-Host ""
|
||||
Write-Host "To fix this issue:"
|
||||
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
|
||||
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 3. Ensure the date is after $lastBaseMigration"
|
||||
Write-Host ""
|
||||
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
|
||||
exit 0
|
||||
@@ -473,6 +473,7 @@ public class OrganizationsController : Controller
|
||||
organization.UseOrganizationDomains = model.UseOrganizationDomains;
|
||||
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
||||
organization.UsePhishingBlocker = model.UsePhishingBlocker;
|
||||
|
||||
//secrets
|
||||
organization.SmSeats = model.SmSeats;
|
||||
|
||||
@@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
|
||||
UseOrganizationDomains = org.UseOrganizationDomains;
|
||||
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
||||
UsePhishingBlocker = org.UsePhishingBlocker;
|
||||
|
||||
_plans = plans;
|
||||
}
|
||||
@@ -160,6 +161,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
public new bool UseSecretsManager { get; set; }
|
||||
[Display(Name = "Risk Insights")]
|
||||
public new bool UseRiskInsights { get; set; }
|
||||
[Display(Name = "Phishing Blocker")]
|
||||
public new bool UsePhishingBlocker { get; set; }
|
||||
[Display(Name = "Admin Sponsored Families")]
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
[Display(Name = "Self Host")]
|
||||
@@ -327,6 +330,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
existingOrganization.SmServiceAccounts = SmServiceAccounts;
|
||||
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
||||
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
||||
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
|
||||
return existingOrganization;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ public class OrganizationViewModel
|
||||
public int OccupiedSmSeatsCount { get; set; }
|
||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||
public bool UseRiskInsights => Organization.UseRiskInsights;
|
||||
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
|
||||
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
||||
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="UsePhishingBlocker" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UsePhishingBlocker"></label>
|
||||
</div>
|
||||
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
<div class="form-check">
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
public virtual async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Wait 20 seconds to allow database to come online
|
||||
await Task.Delay(20000);
|
||||
await Task.Delay(20000, cancellationToken);
|
||||
|
||||
var maxMigrationAttempts = 10;
|
||||
for (var i = 1; i <= maxMigrationAttempts; i++)
|
||||
@@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
{
|
||||
_logger.LogError(e,
|
||||
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
||||
await Task.Delay(20000);
|
||||
await Task.Delay(20000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
||||
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
|
||||
UseSecretsManager = organizationDetails.UseSecretsManager;
|
||||
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
|
||||
UsePasswordManager = organizationDetails.UsePasswordManager;
|
||||
SelfHost = organizationDetails.SelfHost;
|
||||
Seats = organizationDetails.Seats;
|
||||
@@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
public bool SelfHost { get; set; }
|
||||
public int? Seats { get; set; }
|
||||
public short? MaxCollections { get; set; }
|
||||
|
||||
@@ -71,6 +71,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@@ -120,6 +121,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@ public class HibpController : Controller
|
||||
}
|
||||
else if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
/* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches,
|
||||
an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could
|
||||
not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */
|
||||
return Content("[]", "application/json");
|
||||
}
|
||||
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
|
||||
{
|
||||
|
||||
@@ -116,4 +116,10 @@ public interface IStripeFacade
|
||||
TestClockGetOptions testClockGetOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Coupon> GetCoupon(
|
||||
string couponId,
|
||||
CouponGetOptions couponGetOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade
|
||||
private readonly DiscountService _discountService = new();
|
||||
private readonly SetupIntentService _setupIntentService = new();
|
||||
private readonly TestClockService _testClockService = new();
|
||||
private readonly CouponService _couponService = new();
|
||||
|
||||
public async Task<Charge> GetCharge(
|
||||
string chargeId,
|
||||
@@ -143,4 +144,11 @@ public class StripeFacade : IStripeFacade
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken);
|
||||
|
||||
public Task<Coupon> GetCoupon(
|
||||
string couponId,
|
||||
CouponGetOptions couponGetOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core;
|
||||
using System.Globalization;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@@ -8,7 +9,8 @@ using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
|
||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -16,6 +18,7 @@ using Bit.Core.Services;
|
||||
using Stripe;
|
||||
using Event = Stripe.Event;
|
||||
using Plan = Bit.Core.Models.StaticStore.Plan;
|
||||
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
@@ -107,13 +110,22 @@ public class UpcomingInvoiceHandler(
|
||||
|
||||
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
|
||||
|
||||
await AlignOrganizationSubscriptionConcernsAsync(
|
||||
var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync(
|
||||
organization,
|
||||
@event,
|
||||
subscription,
|
||||
plan,
|
||||
milestone3);
|
||||
|
||||
/*
|
||||
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
|
||||
* with processing.
|
||||
*/
|
||||
if (subscriptionAligned)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't send the upcoming invoice email unless the organization's on an annual plan.
|
||||
if (!plan.IsAnnual)
|
||||
{
|
||||
@@ -135,9 +147,7 @@ public class UpcomingInvoiceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
await (milestone3
|
||||
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
|
||||
: SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice));
|
||||
await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice);
|
||||
}
|
||||
|
||||
private async Task AlignOrganizationTaxConcernsAsync(
|
||||
@@ -188,7 +198,16 @@ public class UpcomingInvoiceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignOrganizationSubscriptionConcernsAsync(
|
||||
/// <summary>
|
||||
/// Aligns the organization's subscription details with the specified plan and milestone requirements.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization whose subscription is being updated.</param>
|
||||
/// <param name="event">The Stripe event associated with this operation.</param>
|
||||
/// <param name="subscription">The organization's subscription.</param>
|
||||
/// <param name="plan">The organization's current plan.</param>
|
||||
/// <param name="milestone3">A flag indicating whether the third milestone is enabled.</param>
|
||||
/// <returns>Whether the operation resulted in an updated subscription.</returns>
|
||||
private async Task<bool> AlignOrganizationSubscriptionConcernsAsync(
|
||||
Organization organization,
|
||||
Event @event,
|
||||
Subscription subscription,
|
||||
@@ -198,7 +217,7 @@ public class UpcomingInvoiceHandler(
|
||||
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
|
||||
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var passwordManagerItem =
|
||||
@@ -208,15 +227,15 @@ public class UpcomingInvoiceHandler(
|
||||
{
|
||||
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
|
||||
organization.Id, @event.Type, @event.Id);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
|
||||
var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
|
||||
|
||||
organization.PlanType = families.Type;
|
||||
organization.Plan = families.Name;
|
||||
organization.UsersGetPremium = families.UsersGetPremium;
|
||||
organization.Seats = families.PasswordManager.BaseSeats;
|
||||
organization.PlanType = familiesPlan.Type;
|
||||
organization.Plan = familiesPlan.Name;
|
||||
organization.UsersGetPremium = familiesPlan.UsersGetPremium;
|
||||
organization.Seats = familiesPlan.PasswordManager.BaseSeats;
|
||||
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
@@ -225,7 +244,7 @@ public class UpcomingInvoiceHandler(
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Price = families.PasswordManager.StripePlanId
|
||||
Price = familiesPlan.PasswordManager.StripePlanId
|
||||
}
|
||||
],
|
||||
ProrationBehavior = ProrationBehavior.None
|
||||
@@ -266,6 +285,8 @@ public class UpcomingInvoiceHandler(
|
||||
{
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
await stripeFacade.UpdateSubscription(subscription.Id, options);
|
||||
await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -275,6 +296,7 @@ public class UpcomingInvoiceHandler(
|
||||
organization.Id,
|
||||
@event.Type,
|
||||
@event.Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,14 +325,21 @@ public class UpcomingInvoiceHandler(
|
||||
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||
if (milestone2Feature)
|
||||
{
|
||||
await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
|
||||
var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
|
||||
|
||||
/*
|
||||
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
|
||||
* with processing.
|
||||
*/
|
||||
if (subscriptionAligned)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
await (milestone2Feature
|
||||
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
|
||||
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
|
||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +370,7 @@ public class UpcomingInvoiceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignPremiumUsersSubscriptionConcernsAsync(
|
||||
private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(
|
||||
User user,
|
||||
Event @event,
|
||||
Subscription subscription)
|
||||
@@ -352,7 +381,7 @@ public class UpcomingInvoiceHandler(
|
||||
{
|
||||
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
|
||||
user.Id, @event.Type, @event.Id);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -371,6 +400,8 @@ public class UpcomingInvoiceHandler(
|
||||
],
|
||||
ProrationBehavior = ProrationBehavior.None
|
||||
});
|
||||
await SendPremiumRenewalEmailAsync(user, plan);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -379,6 +410,7 @@ public class UpcomingInvoiceHandler(
|
||||
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
|
||||
user.Id,
|
||||
@event.Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,15 +545,79 @@ public class UpcomingInvoiceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
|
||||
private async Task SendFamiliesRenewalEmailAsync(
|
||||
Organization organization,
|
||||
Plan familiesPlan,
|
||||
Plan planBeforeAlignment)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
|
||||
await (planBeforeAlignment switch
|
||||
{
|
||||
ToEmails = validEmails,
|
||||
View = new UpdatedInvoiceUpcomingView()
|
||||
{ Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan),
|
||||
{ Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan),
|
||||
_ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().")
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan)
|
||||
{
|
||||
var email = new Families2020RenewalMail
|
||||
{
|
||||
ToEmails = [organization.BillingEmail],
|
||||
View = new Families2020RenewalMailView
|
||||
{
|
||||
MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||
}
|
||||
};
|
||||
await mailer.SendEmail(updatedUpcomingEmail);
|
||||
|
||||
await mailer.SendEmail(email);
|
||||
}
|
||||
|
||||
private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan)
|
||||
{
|
||||
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
if (coupon == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found");
|
||||
}
|
||||
|
||||
if (coupon.PercentOff == null)
|
||||
{
|
||||
throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null");
|
||||
}
|
||||
|
||||
var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100;
|
||||
|
||||
var email = new Families2019RenewalMail
|
||||
{
|
||||
ToEmails = [organization.BillingEmail],
|
||||
View = new Families2019RenewalMailView
|
||||
{
|
||||
BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")),
|
||||
BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")),
|
||||
DiscountAmount = $"{coupon.PercentOff}%",
|
||||
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
|
||||
}
|
||||
};
|
||||
|
||||
await mailer.SendEmail(email);
|
||||
}
|
||||
|
||||
private async Task SendPremiumRenewalEmailAsync(
|
||||
User user,
|
||||
PremiumPlan premiumPlan)
|
||||
{
|
||||
/* TODO: Replace with proper premium renewal email template once finalized.
|
||||
Using Families2020RenewalMail as a temporary stop-gap. */
|
||||
var email = new Families2020RenewalMail
|
||||
{
|
||||
ToEmails = [user.Email],
|
||||
View = new Families2020RenewalMailView
|
||||
{
|
||||
MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))
|
||||
}
|
||||
};
|
||||
|
||||
await mailer.SendEmail(email);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -134,6 +134,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
||||
/// </summary>
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set to true, the organization has phishing protection enabled.
|
||||
/// </summary>
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
if (Id == default(Guid))
|
||||
@@ -334,5 +339,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
||||
UseOrganizationDomains = license.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
||||
UsePhishingBlocker = license.UsePhishingBlocker;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails
|
||||
bool UseAdminSponsoredFamilies { get; set; }
|
||||
bool UseOrganizationDomains { get; set; }
|
||||
bool UseAutomaticUserConfirmation { get; set; }
|
||||
bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public class OrganizationAbility
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@@ -51,4 +52,5 @@ public class OrganizationAbility
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -65,4 +65,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool? IsAdminInitiated { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
Status = Status,
|
||||
UseRiskInsights = UseRiskInsights,
|
||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||
UsePhishingBlocker = UsePhishingBlocker,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
|
||||
public string? SsoExternalId { get; set; }
|
||||
public string? Permissions { get; set; }
|
||||
public string? ResetPasswordKey { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Automatic User Confirmation
|
||||
|
||||
Owned by: admin-console
|
||||
|
||||
Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model
|
||||
for the workflow is as follows:
|
||||
|
||||
- The Api server sends an invite email to a user.
|
||||
- The user accepts the invite request, which is sent back to the Api server
|
||||
- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session.
|
||||
- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server
|
||||
- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB
|
||||
|
||||
This Feature has the following security measures in place in order to achieve our security goals:
|
||||
|
||||
- The single organization exemption for admins/owners is removed for this policy.
|
||||
- This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users
|
||||
- Emergency access is removed for all organization users
|
||||
- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization)
|
||||
- The organization has no members with the Provider user type.
|
||||
- This will also prevent the policy and organization plan feature from being enabled
|
||||
- This will prevent sending organization invites to provider users
|
||||
@@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
@@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand
|
||||
private readonly IReadOnlyDictionary<PolicyType, IPolicyValidator> _policyValidators;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IPostSavePolicySideEffect _postSavePolicySideEffect;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public SavePolicyCommand(IApplicationCacheService applicationCacheService,
|
||||
IEventService eventService,
|
||||
IPolicyRepository policyRepository,
|
||||
IEnumerable<IPolicyValidator> policyValidators,
|
||||
TimeProvider timeProvider,
|
||||
IPostSavePolicySideEffect postSavePolicySideEffect)
|
||||
IPostSavePolicySideEffect postSavePolicySideEffect,
|
||||
IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_eventService = eventService;
|
||||
_policyRepository = policyRepository;
|
||||
_timeProvider = timeProvider;
|
||||
_postSavePolicySideEffect = postSavePolicySideEffect;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
|
||||
var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>();
|
||||
foreach (var policyValidator in policyValidators)
|
||||
@@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand
|
||||
await _policyRepository.UpsertAsync(policy);
|
||||
await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated);
|
||||
|
||||
await PushPolicyUpdateToClients(policy.OrganizationId, policy);
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
@@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand
|
||||
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type);
|
||||
return (savedPoliciesDict, currentPolicy);
|
||||
}
|
||||
|
||||
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
|
||||
{
|
||||
Type = PushType.PolicyChanged,
|
||||
Target = NotificationTarget.Organization,
|
||||
TargetId = organizationId,
|
||||
ExcludeCurrentContext = false,
|
||||
Payload = new SyncPolicyPushNotification
|
||||
{
|
||||
Policy = policy,
|
||||
OrganizationId = organizationId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
@@ -15,7 +17,8 @@ public class VNextSavePolicyCommand(
|
||||
IPolicyRepository policyRepository,
|
||||
IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers,
|
||||
TimeProvider timeProvider,
|
||||
IPolicyEventHandlerFactory policyEventHandlerFactory)
|
||||
IPolicyEventHandlerFactory policyEventHandlerFactory,
|
||||
IPushNotificationService pushNotificationService)
|
||||
: IVNextSavePolicyCommand
|
||||
{
|
||||
|
||||
@@ -74,7 +77,7 @@ public class VNextSavePolicyCommand(
|
||||
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
await policyRepository.UpsertAsync(policy);
|
||||
|
||||
await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
@@ -192,4 +195,17 @@ public class VNextSavePolicyCommand(
|
||||
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
|
||||
return savedPoliciesDict;
|
||||
}
|
||||
|
||||
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
|
||||
{
|
||||
Type = PushType.PolicyChanged,
|
||||
Target = NotificationTarget.Organization,
|
||||
TargetId = organizationId,
|
||||
ExcludeCurrentContext = false,
|
||||
Payload = new SyncPolicyPushNotification
|
||||
{
|
||||
Policy = policy,
|
||||
OrganizationId = organizationId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, BlockClaimedDomainAccountCreationPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, AutomaticUserConfirmationPolicyEventHandler>();
|
||||
}
|
||||
|
||||
[Obsolete("Use AddPolicyUpdateEvents instead.")]
|
||||
|
||||
@@ -62,6 +62,7 @@ public static class OrganizationFactory
|
||||
UseAdminSponsoredFamilies =
|
||||
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies),
|
||||
UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation),
|
||||
UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker),
|
||||
};
|
||||
|
||||
public static Organization Create(
|
||||
@@ -111,6 +112,7 @@ public static class OrganizationFactory
|
||||
UseRiskInsights = license.UseRiskInsights,
|
||||
UseOrganizationDomains = license.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation,
|
||||
UsePhishingBlocker = license.UsePhishingBlocker,
|
||||
};
|
||||
}
|
||||
|
||||
23
src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs
Normal file
23
src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Auth.Sso;
|
||||
|
||||
/// <summary>
|
||||
/// Query to retrieve the SSO organization identifier that a user is a confirmed member of.
|
||||
/// </summary>
|
||||
public interface IUserSsoOrganizationIdentifierQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the SSO organization identifier for a confirmed organization user.
|
||||
/// If there is more than one organization a User is associated with, we return null. If there are more than one
|
||||
/// organization there is no way to know which organization the user wishes to authenticate with.
|
||||
/// Owners and Admins who are not subject to the SSO required policy cannot utilize this flow, since they may have
|
||||
/// multiple organizations with different SSO configurations.
|
||||
/// </summary>
|
||||
/// <param name="userId">The ID of the <see cref="User"/> to retrieve the SSO organization for. _Not_ an <see cref="OrganizationUser"/>.</param>
|
||||
/// <returns>
|
||||
/// The organization identifier if the user is a confirmed member of an organization with SSO configured,
|
||||
/// otherwise null
|
||||
/// </returns>
|
||||
Task<string?> GetSsoOrganizationIdentifierAsync(Guid userId);
|
||||
}
|
||||
38
src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs
Normal file
38
src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Auth.Sso;
|
||||
|
||||
/// <summary>
|
||||
/// TODO : PM-28846 review data structures as they relate to this query
|
||||
/// Query to retrieve the SSO organization identifier that a user is a confirmed member of.
|
||||
/// </summary>
|
||||
public class UserSsoOrganizationIdentifierQuery(
|
||||
IOrganizationUserRepository _organizationUserRepository,
|
||||
IOrganizationRepository _organizationRepository) : IUserSsoOrganizationIdentifierQuery
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetSsoOrganizationIdentifierAsync(Guid userId)
|
||||
{
|
||||
// Get all confirmed organization memberships for the user
|
||||
var organizationUsers = await _organizationUserRepository.GetManyByUserAsync(userId);
|
||||
|
||||
// we can only confidently return the correct SsoOrganizationIdentifier if there is exactly one Organization.
|
||||
// The user must also be in the Confirmed status.
|
||||
var confirmedOrgUsers = organizationUsers.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed);
|
||||
if (confirmedOrgUsers.Count() != 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var confirmedOrgUser = confirmedOrgUsers.Single();
|
||||
var organization = await _organizationRepository.GetByIdAsync(confirmedOrgUser.OrganizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return organization.Identifier;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
using Bit.Core.Auth.Sso;
|
||||
using Bit.Core.Auth.UserFeatures.DeviceTrust;
|
||||
using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
|
||||
@@ -29,6 +28,7 @@ public static class UserServiceCollectionExtensions
|
||||
services.AddWebAuthnLoginCommands();
|
||||
services.AddTdeOffboardingPasswordCommands();
|
||||
services.AddTwoFactorQueries();
|
||||
services.AddSsoQueries();
|
||||
}
|
||||
|
||||
public static void AddDeviceTrustCommands(this IServiceCollection services)
|
||||
@@ -69,4 +69,9 @@ public static class UserServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<ITwoFactorIsEnabledQuery, TwoFactorIsEnabledQuery>();
|
||||
}
|
||||
|
||||
private static void AddSsoQueries(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IUserSsoOrganizationIdentifierQuery, UserSsoOrganizationIdentifierQuery>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public static class OrganizationLicenseConstants
|
||||
public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies);
|
||||
public const string UseOrganizationDomains = nameof(UseOrganizationDomains);
|
||||
public const string UseAutomaticUserConfirmation = nameof(UseAutomaticUserConfirmation);
|
||||
public const string UsePhishingBlocker = nameof(UsePhishingBlocker);
|
||||
}
|
||||
|
||||
public static class UserLicenseConstants
|
||||
|
||||
@@ -57,6 +57,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
||||
new(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UseOrganizationDomains), entity.UseOrganizationDomains.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UseAutomaticUserConfirmation), entity.UseAutomaticUserConfirmation.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UsePhishingBlocker), entity.UsePhishingBlocker.ToString()),
|
||||
};
|
||||
|
||||
if (entity.Name is not null)
|
||||
|
||||
@@ -143,6 +143,7 @@ public class OrganizationLicense : ILicense
|
||||
public int? SmSeats { get; set; }
|
||||
public int? SmServiceAccounts { get; set; }
|
||||
public bool UseRiskInsights { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
|
||||
// Deprecated. Left for backwards compatibility with old license versions.
|
||||
public bool LimitCollectionCreationDeletion { get; set; } = true;
|
||||
@@ -228,7 +229,8 @@ public class OrganizationLicense : ILicense
|
||||
!p.Name.Equals(nameof(UseRiskInsights)) &&
|
||||
!p.Name.Equals(nameof(UseAdminSponsoredFamilies)) &&
|
||||
!p.Name.Equals(nameof(UseOrganizationDomains)) &&
|
||||
!p.Name.Equals(nameof(UseAutomaticUserConfirmation)))
|
||||
!p.Name.Equals(nameof(UseAutomaticUserConfirmation)) &&
|
||||
!p.Name.Equals(nameof(UsePhishingBlocker)))
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}")
|
||||
.Aggregate((c, n) => $"{c}|{n}");
|
||||
|
||||
@@ -166,6 +166,7 @@ public static class FeatureFlagKeys
|
||||
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
|
||||
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
|
||||
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
|
||||
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
|
||||
|
||||
/* Autofill Team */
|
||||
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
|
||||
@@ -202,14 +203,11 @@ public static class FeatureFlagKeys
|
||||
|
||||
/* Key Management Team */
|
||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
||||
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
|
||||
public const string Argon2Default = "argon2-default";
|
||||
public const string UserkeyRotationV2 = "userkey-rotation-v2";
|
||||
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
|
||||
public const string UserSdkForDecryption = "use-sdk-for-decryption";
|
||||
public const string EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation";
|
||||
public const string PM17987_BlockType0 = "pm-17987-block-type-0";
|
||||
public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings";
|
||||
public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data";
|
||||
public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2";
|
||||
|
||||
@@ -38,10 +38,6 @@ public class CurrentContext(
|
||||
public virtual List<CurrentContextProvider> Providers { get; set; }
|
||||
public virtual Guid? InstallationId { get; set; }
|
||||
public virtual Guid? OrganizationId { get; set; }
|
||||
public virtual bool CloudflareWorkerProxied { get; set; }
|
||||
public virtual bool IsBot { get; set; }
|
||||
public virtual bool MaybeBot { get; set; }
|
||||
public virtual int? BotScore { get; set; }
|
||||
public virtual string ClientId { get; set; }
|
||||
public virtual Version ClientVersion { get; set; }
|
||||
public virtual bool ClientVersionIsPrerelease { get; set; }
|
||||
@@ -70,27 +66,6 @@ public class CurrentContext(
|
||||
DeviceType = dType;
|
||||
}
|
||||
|
||||
if (!BotScore.HasValue && httpContext.Request.Headers.TryGetValue("X-Cf-Bot-Score", out var cfBotScore) &&
|
||||
int.TryParse(cfBotScore, out var parsedBotScore))
|
||||
{
|
||||
BotScore = parsedBotScore;
|
||||
}
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied))
|
||||
{
|
||||
CloudflareWorkerProxied = cfWorkedProxied == "1";
|
||||
}
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot))
|
||||
{
|
||||
IsBot = cfIsBot == "1";
|
||||
}
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot))
|
||||
{
|
||||
MaybeBot = cfMaybeBot == "1";
|
||||
}
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion))
|
||||
{
|
||||
ClientVersion = cVersion;
|
||||
|
||||
@@ -31,9 +31,6 @@ public interface ICurrentContext
|
||||
Guid? InstallationId { get; set; }
|
||||
Guid? OrganizationId { get; set; }
|
||||
IdentityClientType IdentityClientType { get; set; }
|
||||
bool IsBot { get; set; }
|
||||
bool MaybeBot { get; set; }
|
||||
int? BotScore { get; set; }
|
||||
string ClientId { get; set; }
|
||||
Version ClientVersion { get; set; }
|
||||
bool ClientVersionIsPrerelease { get; set; }
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.1.3" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="4.0.1.5" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.19.1" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.20.1" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.24.0" />
|
||||
<PackageReference Include="BitPay.Light" Version="1.0.1907" />
|
||||
<PackageReference Include="DuoUniversal" Version="1.3.1" />
|
||||
<PackageReference Include="DnsClient" Version="1.8.0" />
|
||||
@@ -60,9 +60,9 @@
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.10.4" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.1.2" />
|
||||
<PackageReference Include="ZiggyCreatures.FusionCache" Version="2.0.2" />
|
||||
<PackageReference Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" Version="2.0.2" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"packages": [
|
||||
"components/mj-bw-hero",
|
||||
"components/mj-bw-simple-hero",
|
||||
"components/mj-bw-icon-row",
|
||||
"components/mj-bw-learn-more-footer",
|
||||
"emails/AdminConsole/components/mj-bw-inviter-info"
|
||||
|
||||
40
src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js
Normal file
40
src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { BodyComponent } = require("mjml-core");
|
||||
|
||||
class MjBwSimpleHero extends BodyComponent {
|
||||
static dependencies = {
|
||||
// Tell the validator which tags are allowed as our component's parent
|
||||
"mj-column": ["mj-bw-simple-hero"],
|
||||
"mj-wrapper": ["mj-bw-simple-hero"],
|
||||
// Tell the validator which tags are allowed as our component's children
|
||||
"mj-bw-simple-hero": [],
|
||||
};
|
||||
|
||||
static allowedAttributes = {};
|
||||
|
||||
static defaultAttributes = {};
|
||||
|
||||
render() {
|
||||
return this.renderMJML(
|
||||
`
|
||||
<mj-section
|
||||
full-width="full-width"
|
||||
background-color="#175ddc"
|
||||
border-radius="4px 4px 0px 0px"
|
||||
padding="20px 20px"
|
||||
>
|
||||
<mj-column width="100%">
|
||||
<mj-image
|
||||
align="left"
|
||||
src="https://bitwarden.com/images/logo-horizontal-white.png"
|
||||
width="150px"
|
||||
height="30px"
|
||||
padding="10px 5px"
|
||||
></mj-image>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MjBwSimpleHero;
|
||||
@@ -0,0 +1,42 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml"/>
|
||||
</mj-head>
|
||||
|
||||
<!-- Blue Header Section-->
|
||||
<mj-body css-class="border-fix">
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 0px 20px">
|
||||
<mj-bw-simple-hero />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content Section -->
|
||||
<mj-wrapper padding="0px 20px 0px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
|
||||
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually
|
||||
at {{BaseAnnualRenewalPrice}} + tax.
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
|
||||
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
Questions? Contact
|
||||
<a href="mailto:support@bitwarden.com" class="link">support@bitwarden.com</a>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="0px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml"/>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -0,0 +1,36 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<!-- Blue Header Section-->
|
||||
<mj-body css-class="border-fix">
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 0px 20px">
|
||||
<mj-bw-simple-hero />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content Section -->
|
||||
<mj-wrapper padding="0px 20px 0px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
|
||||
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually.
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
Questions? Contact <a href="mailto:support@bitwarden.com" class="link">support@bitwarden.com</a>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="0px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,27 +0,0 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../components/head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<mj-body background-color="#f6f6f6">
|
||||
<mj-include path="../components/logo.mjml" />
|
||||
|
||||
<mj-wrapper
|
||||
background-color="#fff"
|
||||
border="1px solid #e9e9e9"
|
||||
css-class="border-fix"
|
||||
padding="0"
|
||||
>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.
|
||||
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<mj-include path="../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
||||
|
||||
public class Families2019RenewalMailView : BaseMailView
|
||||
{
|
||||
public required string BaseMonthlyRenewalPrice { get; set; }
|
||||
public required string BaseAnnualRenewalPrice { get; set; }
|
||||
public required string DiscountedAnnualRenewalPrice { get; set; }
|
||||
public required string DiscountAmount { get; set; }
|
||||
}
|
||||
|
||||
public class Families2019RenewalMail : BaseMail<Families2019RenewalMailView>
|
||||
{
|
||||
public override string Subject { get => "Your Bitwarden Families renewal is updating"; }
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
<!doctype html>
|
||||
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title></title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a { padding:0; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:580px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 5px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Main Content Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually
|
||||
at {{BaseAnnualRenewalPrice}} + tax.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Questions? Contact
|
||||
<a href="mailto:support@bitwarden.com" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">support@bitwarden.com</a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:94px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually
|
||||
at {{BaseAnnualRenewalPrice}} + tax.
|
||||
|
||||
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
|
||||
|
||||
Questions? Contact support@bitwarden.com
|
||||
@@ -0,0 +1,13 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||
|
||||
public class Families2020RenewalMailView : BaseMailView
|
||||
{
|
||||
public required string MonthlyRenewalPrice { get; set; }
|
||||
}
|
||||
|
||||
public class Families2020RenewalMail : BaseMail<Families2020RenewalMailView>
|
||||
{
|
||||
public override string Subject { get => "Your Bitwarden Families renewal is updating"; }
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
<!doctype html>
|
||||
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title></title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a { padding:0; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
Your Bitwarden Families renewal is updating
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:155px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/account-fill.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Main Content Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Questions? Contact <a href="mailto:support@bitwarden.com" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">support@bitwarden.com</a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:94px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually.
|
||||
|
||||
Questions? Contact support@bitwarden.com
|
||||
@@ -1,10 +0,0 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
|
||||
|
||||
public class UpdatedInvoiceUpcomingView : BaseMailView;
|
||||
|
||||
public class UpdatedInvoiceUpcomingMail : BaseMail<UpdatedInvoiceUpcomingView>
|
||||
{
|
||||
public override string Subject { get => "Your Subscription Will Renew Soon"; }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
||||
{{#>BasicTextLayout}}
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.
|
||||
{{/BasicTextLayout}}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.NotificationCenter.Enums;
|
||||
|
||||
namespace Bit.Core.Models;
|
||||
@@ -103,3 +104,9 @@ public class LogOutPushNotification
|
||||
public Guid UserId { get; set; }
|
||||
public PushNotificationLogOutReason? Reason { get; set; }
|
||||
}
|
||||
|
||||
public class SyncPolicyPushNotification
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public required Policy Policy { get; set; }
|
||||
}
|
||||
|
||||
@@ -95,5 +95,8 @@ public enum PushType : byte
|
||||
OrganizationBankAccountVerified = 23,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))]
|
||||
ProviderBankAccountVerified = 24
|
||||
ProviderBankAccountVerified = 24,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.SyncPolicyPushNotification))]
|
||||
PolicyChanged = 25,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Bit.Identity.IdentityServer.RequestValidationConstants;
|
||||
|
||||
public static class CustomResponseConstants
|
||||
{
|
||||
public static class ResponseKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the error model returned in the custom response when an error occurs.
|
||||
/// </summary>
|
||||
public static string ErrorModel => "ErrorModel";
|
||||
/// <summary>
|
||||
/// This Key is used when a user is in a single organization that requires SSO authentication. The identifier
|
||||
/// is used by the client to speed the redirection to the correct IdP for the user's organization.
|
||||
/// </summary>
|
||||
public static string SsoOrganizationIdentifier => "SsoOrganizationIdentifier";
|
||||
}
|
||||
}
|
||||
|
||||
public static class SsoConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// These are messages and errors we return when SSO Validation is unsuccessful
|
||||
/// </summary>
|
||||
public static class RequestErrors
|
||||
{
|
||||
public static string SsoRequired => "sso_required";
|
||||
public static string SsoRequiredDescription => "Sso authentication is required.";
|
||||
public static string SsoTwoFactorRecoveryDescription => "Two-factor recovery has been performed. SSO authentication is required.";
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IDeviceValidator _deviceValidator;
|
||||
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
|
||||
private readonly ISsoRequestValidator _ssoRequestValidator;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly ILogger _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
@@ -43,7 +44,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
|
||||
protected ICurrentContext CurrentContext { get; }
|
||||
protected IPolicyService PolicyService { get; }
|
||||
protected IFeatureService FeatureService { get; }
|
||||
protected IFeatureService _featureService { get; }
|
||||
protected ISsoConfigRepository SsoConfigRepository { get; }
|
||||
protected IUserService _userService { get; }
|
||||
protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
|
||||
@@ -56,6 +57,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
ISsoRequestValidator ssoRequestValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ILogger logger,
|
||||
ICurrentContext currentContext,
|
||||
@@ -76,13 +78,14 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
_eventService = eventService;
|
||||
_deviceValidator = deviceValidator;
|
||||
_twoFactorAuthenticationValidator = twoFactorAuthenticationValidator;
|
||||
_ssoRequestValidator = ssoRequestValidator;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_logger = logger;
|
||||
CurrentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
PolicyService = policyService;
|
||||
_userRepository = userRepository;
|
||||
FeatureService = featureService;
|
||||
_featureService = featureService;
|
||||
SsoConfigRepository = ssoConfigRepository;
|
||||
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
|
||||
PolicyRequirementQuery = policyRequirementQuery;
|
||||
@@ -94,7 +97,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers))
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers))
|
||||
{
|
||||
var validators = DetermineValidationOrder(context, request, validatorContext);
|
||||
var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators);
|
||||
@@ -120,15 +123,29 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
}
|
||||
|
||||
// 2. Decide if this user belongs to an organization that requires SSO.
|
||||
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
|
||||
if (validatorContext.SsoRequired)
|
||||
// TODO: Clean up Feature Flag: Remove this if block: PM-28281
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired))
|
||||
{
|
||||
SetSsoResult(context,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
});
|
||||
return;
|
||||
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
|
||||
if (validatorContext.SsoRequired)
|
||||
{
|
||||
SetSsoResult(context,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var ssoValid = await _ssoRequestValidator.ValidateAsync(user, request, validatorContext);
|
||||
if (!ssoValid)
|
||||
{
|
||||
// SSO is required
|
||||
SetValidationErrorResult(context, validatorContext);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check if 2FA is required.
|
||||
@@ -355,36 +372,51 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
private async Task<bool> ValidateSsoAsync(T context, ValidatedTokenRequest request,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType);
|
||||
if (!validatorContext.SsoRequired)
|
||||
// TODO: Clean up Feature Flag: Remove this if block: PM-28281
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType);
|
||||
if (!validatorContext.SsoRequired)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are
|
||||
// presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and
|
||||
// review their new recovery token if desired.
|
||||
// SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery.
|
||||
// As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been
|
||||
// evaluated, and recovery will have been performed if requested.
|
||||
// We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect
|
||||
// to /login.
|
||||
if (validatorContext.TwoFactorRequired &&
|
||||
validatorContext.TwoFactorRecoveryRequested)
|
||||
{
|
||||
SetSsoResult(context, new Dictionary<string, object>
|
||||
// Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are
|
||||
// presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and
|
||||
// review their new recovery token if desired.
|
||||
// SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery.
|
||||
// As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been
|
||||
// evaluated, and recovery will have been performed if requested.
|
||||
// We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect
|
||||
// to /login.
|
||||
if (validatorContext.TwoFactorRequired &&
|
||||
validatorContext.TwoFactorRecoveryRequested)
|
||||
{
|
||||
SetSsoResult(context, new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
SetSsoResult(context,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
SetSsoResult(context,
|
||||
new Dictionary<string, object>
|
||||
else
|
||||
{
|
||||
var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext);
|
||||
if (ssoValid)
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
});
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
SetValidationErrorResult(context, validatorContext);
|
||||
return ssoValid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -651,6 +683,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
/// <param name="user">user trying to login</param>
|
||||
/// <param name="grantType">magic string identifying the grant type requested</param>
|
||||
/// <returns>true if sso required; false if not required or already in process</returns>
|
||||
[Obsolete("This method is deprecated and will be removed in future versions, PM-28281. Please use the SsoRequestValidator scheme instead.")]
|
||||
private async Task<bool> RequireSsoLoginAsync(User user, string grantType)
|
||||
{
|
||||
if (grantType == "authorization_code" || grantType == "client_credentials")
|
||||
@@ -661,7 +694,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
}
|
||||
|
||||
// Check if user belongs to any organization with an active SSO policy
|
||||
var ssoRequired = FeatureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
||||
var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
||||
? (await PolicyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(user.Id))
|
||||
.SsoRequired
|
||||
: await PolicyService.AnyPoliciesApplicableToUserAsync(
|
||||
@@ -703,7 +736,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
|
||||
private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType)
|
||||
{
|
||||
if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
|
||||
{
|
||||
await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow,
|
||||
CurrentContext.IpAddress);
|
||||
|
||||
@@ -36,6 +36,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
ISsoRequestValidator ssoRequestValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ILogger<CustomTokenRequestValidator> logger,
|
||||
ICurrentContext currentContext,
|
||||
@@ -56,6 +57,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
eventService,
|
||||
deviceValidator,
|
||||
twoFactorAuthenticationValidator,
|
||||
ssoRequestValidator,
|
||||
organizationUserRepository,
|
||||
logger,
|
||||
currentContext,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Bit.Core.Entities;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether a user is required to authenticate via SSO based on organization policies.
|
||||
/// </summary>
|
||||
public interface ISsoRequestValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the SSO requirement for a user attempting to authenticate. Sets the error state in the <see cref="CustomValidatorRequestContext.CustomResponse"/> if SSO is required.
|
||||
/// </summary>
|
||||
/// <param name="user">The user attempting to authenticate.</param>
|
||||
/// <param name="request">The token request containing grant type and other authentication details.</param>
|
||||
/// <param name="context">The validator context to be updated with SSO requirement status and error results if applicable.</param>
|
||||
/// <returns>true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow.</returns>
|
||||
Task<bool> ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
ISsoRequestValidator ssoRequestValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ILogger<ResourceOwnerPasswordValidator> logger,
|
||||
ICurrentContext currentContext,
|
||||
@@ -50,6 +51,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
eventService,
|
||||
deviceValidator,
|
||||
twoFactorAuthenticationValidator,
|
||||
ssoRequestValidator,
|
||||
organizationUserRepository,
|
||||
logger,
|
||||
currentContext,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Sso;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Identity.IdentityServer.RequestValidationConstants;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether a user is required to authenticate via SSO based on organization policies.
|
||||
/// </summary>
|
||||
public class SsoRequestValidator(
|
||||
IPolicyService _policyService,
|
||||
IFeatureService _featureService,
|
||||
IUserSsoOrganizationIdentifierQuery _userSsoOrganizationIdentifierQuery,
|
||||
IPolicyRequirementQuery _policyRequirementQuery) : ISsoRequestValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the SSO requirement for a user attempting to authenticate.
|
||||
/// Sets context.SsoRequired to indicate whether SSO is required.
|
||||
/// If SSO is required, sets the validation error result and custom response in the context.
|
||||
/// </summary>
|
||||
/// <param name="user">The user attempting to authenticate.</param>
|
||||
/// <param name="request">The token request containing grant type and other authentication details.</param>
|
||||
/// <param name="context">The validator context to be updated with SSO requirement status and error results if applicable.</param>
|
||||
/// <returns>true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow.</returns>
|
||||
public async Task<bool> ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context)
|
||||
{
|
||||
context.SsoRequired = await RequireSsoAuthenticationAsync(user, request.GrantType);
|
||||
|
||||
if (!context.SsoRequired)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are
|
||||
// presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and
|
||||
// review their new recovery token if desired.
|
||||
// SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery.
|
||||
// As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been
|
||||
// evaluated, and recovery will have been performed if requested.
|
||||
// We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect
|
||||
// to /login.
|
||||
// If the feature flag RecoveryCodeSupportForSsoRequiredUsers is set to false then this code is unreachable since
|
||||
// Two Factor validation occurs after SSO validation in that scenario.
|
||||
if (context.TwoFactorRequired && context.TwoFactorRecoveryRequested)
|
||||
{
|
||||
await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription);
|
||||
return false;
|
||||
}
|
||||
|
||||
await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoRequiredDescription);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are
|
||||
/// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
|
||||
/// If the GrantType is authorization_code or client_credentials we know the user is trying to login
|
||||
/// using the SSO flow so they are allowed to continue.
|
||||
/// </summary>
|
||||
/// <param name="user">user trying to login</param>
|
||||
/// <param name="grantType">magic string identifying the grant type requested</param>
|
||||
/// <returns>true if sso required; false if not required or already in process</returns>
|
||||
private async Task<bool> RequireSsoAuthenticationAsync(User user, string grantType)
|
||||
{
|
||||
if (grantType == OidcConstants.GrantTypes.AuthorizationCode ||
|
||||
grantType == OidcConstants.GrantTypes.ClientCredentials)
|
||||
{
|
||||
// SSO is not required for users already using SSO to authenticate which uses the authorization_code grant type,
|
||||
// or logging-in via API key which is the client_credentials grant type.
|
||||
// Allow user to continue request validation
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user belongs to any organization with an active SSO policy
|
||||
var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
||||
? (await _policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(user.Id))
|
||||
.SsoRequired
|
||||
: await _policyService.AnyPoliciesApplicableToUserAsync(
|
||||
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
|
||||
if (ssoRequired)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default - SSO is not required
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the customResponse in the context with the error result for the SSO validation failure.
|
||||
/// </summary>
|
||||
/// <param name="context">The validator context to update with error details.</param>
|
||||
/// <param name="errorMessage">The error message to return to the client.</param>
|
||||
private async Task SetContextCustomResponseSsoErrorAsync(CustomValidatorRequestContext context, string errorMessage)
|
||||
{
|
||||
var ssoOrganizationIdentifier = await _userSsoOrganizationIdentifierQuery.GetSsoOrganizationIdentifierAsync(context.User.Id);
|
||||
|
||||
context.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = OidcConstants.TokenErrors.InvalidGrant,
|
||||
ErrorDescription = errorMessage
|
||||
};
|
||||
|
||||
context.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(errorMessage) }
|
||||
};
|
||||
|
||||
// Include organization identifier in the response if available
|
||||
if (!string.IsNullOrEmpty(ssoOrganizationIdentifier))
|
||||
{
|
||||
context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier] = ssoOrganizationIdentifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
ISsoRequestValidator ssoRequestValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ILogger<CustomTokenRequestValidator> logger,
|
||||
ICurrentContext currentContext,
|
||||
@@ -59,6 +60,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
eventService,
|
||||
deviceValidator,
|
||||
twoFactorAuthenticationValidator,
|
||||
ssoRequestValidator,
|
||||
organizationUserRepository,
|
||||
logger,
|
||||
currentContext,
|
||||
|
||||
@@ -26,6 +26,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
|
||||
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
||||
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
||||
services.AddTransient<ISsoRequestValidator, SsoRequestValidator>();
|
||||
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
||||
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
|
||||
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();
|
||||
|
||||
@@ -113,7 +113,8 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
||||
UseRiskInsights = e.UseRiskInsights,
|
||||
UseOrganizationDomains = e.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = e.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = e.UseAutomaticUserConfirmation
|
||||
UseAutomaticUserConfirmation = e.UseAutomaticUserConfirmation,
|
||||
UsePhishingBlocker = e.UsePhishingBlocker
|
||||
}).ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery<OrganizationU
|
||||
LimitItemDeletion = o.LimitItemDeletion,
|
||||
IsAdminInitiated = os.IsAdminInitiated,
|
||||
UseOrganizationDomains = o.UseOrganizationDomains,
|
||||
UseAutomaticUserConfirmation = o.UseAutomaticUserConfirmation
|
||||
UseAutomaticUserConfirmation = o.UseAutomaticUserConfirmation,
|
||||
UsePhishingBlocker = o.UsePhishingBlocker
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery<ProviderUserOrgan
|
||||
UseAutomaticUserConfirmation = x.o.UseAutomaticUserConfirmation,
|
||||
SsoEnabled = x.ss.Enabled,
|
||||
SsoConfig = x.ss.Data,
|
||||
UsePhishingBlocker = x.o.UsePhishingBlocker
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,9 +231,26 @@ public class HubHelpers
|
||||
await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString())
|
||||
.SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken);
|
||||
break;
|
||||
case PushType.PolicyChanged:
|
||||
await policyChangedNotificationHandler(notificationJson, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task policyChangedNotificationHandler(string notificationJson, CancellationToken cancellationToken)
|
||||
{
|
||||
var policyData = JsonSerializer.Deserialize<PushNotificationData<SyncPolicyPushNotification>>(notificationJson, _deserializerOptions);
|
||||
if (policyData is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _hubContext.Clients
|
||||
.Group(NotificationsHub.GetOrganizationGroup(policyData.Payload.OrganizationId))
|
||||
.SendAsync(_receiveMessageMethod, policyData, cancellationToken);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ CREATE PROCEDURE [dbo].[Organization_Create]
|
||||
@UseOrganizationDomains BIT = 0,
|
||||
@UseAdminSponsoredFamilies BIT = 0,
|
||||
@SyncSeats BIT = 0,
|
||||
@UseAutomaticUserConfirmation BIT = 0
|
||||
@UseAutomaticUserConfirmation BIT = 0,
|
||||
@UsePhishingBlocker BIT = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@@ -126,7 +127,8 @@ BEGIN
|
||||
[UseOrganizationDomains],
|
||||
[UseAdminSponsoredFamilies],
|
||||
[SyncSeats],
|
||||
[UseAutomaticUserConfirmation]
|
||||
[UseAutomaticUserConfirmation],
|
||||
[UsePhishingBlocker]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@@ -190,6 +192,7 @@ BEGIN
|
||||
@UseOrganizationDomains,
|
||||
@UseAdminSponsoredFamilies,
|
||||
@SyncSeats,
|
||||
@UseAutomaticUserConfirmation
|
||||
@UseAutomaticUserConfirmation,
|
||||
@UsePhishingBlocker
|
||||
);
|
||||
END
|
||||
|
||||
@@ -28,7 +28,8 @@ BEGIN
|
||||
[LimitItemDeletion],
|
||||
[UseOrganizationDomains],
|
||||
[UseAdminSponsoredFamilies],
|
||||
[UseAutomaticUserConfirmation]
|
||||
[UseAutomaticUserConfirmation],
|
||||
[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[Organization]
|
||||
END
|
||||
|
||||
@@ -59,7 +59,8 @@ CREATE PROCEDURE [dbo].[Organization_Update]
|
||||
@UseOrganizationDomains BIT = 0,
|
||||
@UseAdminSponsoredFamilies BIT = 0,
|
||||
@SyncSeats BIT = 0,
|
||||
@UseAutomaticUserConfirmation BIT = 0
|
||||
@UseAutomaticUserConfirmation BIT = 0,
|
||||
@UsePhishingBlocker BIT = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@@ -126,7 +127,8 @@ BEGIN
|
||||
[UseOrganizationDomains] = @UseOrganizationDomains,
|
||||
[UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies,
|
||||
[SyncSeats] = @SyncSeats,
|
||||
[UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation
|
||||
[UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation,
|
||||
[UsePhishingBlocker] = @UsePhishingBlocker
|
||||
WHERE
|
||||
[Id] = @Id;
|
||||
END
|
||||
|
||||
@@ -61,6 +61,7 @@ CREATE TABLE [dbo].[Organization] (
|
||||
[SyncSeats] BIT NOT NULL CONSTRAINT [DF_Organization_SyncSeats] DEFAULT (0),
|
||||
[UseAutomaticUserConfirmation] BIT NOT NULL CONSTRAINT [DF_Organization_UseAutomaticUserConfirmation] DEFAULT (0),
|
||||
[MaxStorageGbIncreased] SMALLINT NULL,
|
||||
[UsePhishingBlocker] BIT NOT NULL CONSTRAINT [DF_Organization_UsePhishingBlocker] DEFAULT (0),
|
||||
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
|
||||
);
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ SELECT
|
||||
O.[UseAdminSponsoredFamilies],
|
||||
O.[UseOrganizationDomains],
|
||||
OS.[IsAdminInitiated],
|
||||
O.[UseAutomaticUserConfirmation]
|
||||
O.[UseAutomaticUserConfirmation],
|
||||
O.[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[OrganizationUser] OU
|
||||
LEFT JOIN
|
||||
|
||||
@@ -61,6 +61,7 @@ SELECT
|
||||
[UseOrganizationDomains],
|
||||
[UseAdminSponsoredFamilies],
|
||||
[SyncSeats],
|
||||
[UseAutomaticUserConfirmation]
|
||||
[UseAutomaticUserConfirmation],
|
||||
[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[Organization]
|
||||
|
||||
@@ -44,7 +44,8 @@ SELECT
|
||||
O.[UseOrganizationDomains],
|
||||
O.[UseAutomaticUserConfirmation],
|
||||
SS.[Enabled] SsoEnabled,
|
||||
SS.[Data] SsoConfig
|
||||
SS.[Data] SsoConfig,
|
||||
O.[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[ProviderUser] PU
|
||||
INNER JOIN
|
||||
|
||||
@@ -48,6 +48,7 @@ public class ProfileOrganizationResponseModelTests
|
||||
UsersGetPremium = organization.UsersGetPremium,
|
||||
UseCustomPermissions = organization.UseCustomPermissions,
|
||||
UseRiskInsights = organization.UseRiskInsights,
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker,
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
|
||||
|
||||
@@ -45,6 +45,7 @@ public class ProfileProviderOrganizationResponseModelTests
|
||||
UsersGetPremium = organization.UsersGetPremium,
|
||||
UseCustomPermissions = organization.UseCustomPermissions,
|
||||
UseRiskInsights = organization.UseRiskInsights,
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker,
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
|
||||
|
||||
292
test/Api.Test/Dirt/HibpControllerTests.cs
Normal file
292
test/Api.Test/Dirt/HibpControllerTests.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Bit.Api.Dirt.Controllers;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Api.Test.Dirt;
|
||||
|
||||
[ControllerCustomize(typeof(HibpController))]
|
||||
[SutProviderCustomize]
|
||||
public class HibpControllerTests : IDisposable
|
||||
{
|
||||
private readonly HttpClient _originalHttpClient;
|
||||
private readonly FieldInfo _httpClientField;
|
||||
|
||||
public HibpControllerTests()
|
||||
{
|
||||
// Store original HttpClient for restoration
|
||||
_httpClientField = typeof(HibpController).GetField("_httpClient", BindingFlags.Static | BindingFlags.NonPublic);
|
||||
_originalHttpClient = (HttpClient)_httpClientField?.GetValue(null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Restore original HttpClient after tests
|
||||
_httpClientField?.SetValue(null, _originalHttpClient);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_WithMissingApiKey_ThrowsBadRequestException(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
string username)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = null;
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.Get(username));
|
||||
Assert.Equal("HaveIBeenPwned API key not set.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_WithValidApiKeyAndNoBreaches_Returns200WithEmptyArray(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
string username,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
|
||||
var user = new User { Id = userId };
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
|
||||
.Returns(userId);
|
||||
|
||||
// Mock HttpClient to return 404 (no breaches found)
|
||||
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.NotFound, "");
|
||||
_httpClientField.SetValue(null, mockHttpClient);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Get(username);
|
||||
|
||||
// Assert
|
||||
var contentResult = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal("[]", contentResult.Content);
|
||||
Assert.Equal("application/json", contentResult.ContentType);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_WithValidApiKeyAndBreachesFound_Returns200WithBreachData(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
string username,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
|
||||
.Returns(userId);
|
||||
|
||||
var breachData = "[{\"Name\":\"Adobe\",\"Title\":\"Adobe\",\"Domain\":\"adobe.com\"}]";
|
||||
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.OK, breachData);
|
||||
_httpClientField.SetValue(null, mockHttpClient);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Get(username);
|
||||
|
||||
// Assert
|
||||
var contentResult = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal(breachData, contentResult.Content);
|
||||
Assert.Equal("application/json", contentResult.ContentType);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_WithRateLimiting_RetriesWithDelay(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
string username,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
|
||||
.Returns(userId);
|
||||
|
||||
// First response is rate limited, second is success
|
||||
var requestCount = 0;
|
||||
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
|
||||
{
|
||||
requestCount++;
|
||||
if (requestCount == 1)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
|
||||
response.Headers.Add("retry-after", "1");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("")
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var mockHttpClient = new HttpClient(mockHandler);
|
||||
_httpClientField.SetValue(null, mockHttpClient);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Get(username);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, requestCount); // Verify retry happened
|
||||
var contentResult = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal("[]", contentResult.Content);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_WithServerError_ThrowsBadRequestException(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
string username,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
|
||||
.Returns(userId);
|
||||
|
||||
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.InternalServerError, "");
|
||||
_httpClientField.SetValue(null, mockHttpClient);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.Get(username));
|
||||
Assert.Contains("Request failed. Status code:", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_WithBadRequest_ThrowsBadRequestException(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
string username,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
|
||||
.Returns(userId);
|
||||
|
||||
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.BadRequest, "");
|
||||
_httpClientField.SetValue(null, mockHttpClient);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.Get(username));
|
||||
Assert.Contains("Request failed. Status code:", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Get_EncodesUsernameCorrectly(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
var usernameWithSpecialChars = "test+user@example.com";
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
|
||||
.Returns(userId);
|
||||
|
||||
string capturedUrl = null;
|
||||
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
|
||||
{
|
||||
capturedUrl = request.RequestUri.ToString();
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("")
|
||||
});
|
||||
});
|
||||
|
||||
var mockHttpClient = new HttpClient(mockHandler);
|
||||
_httpClientField.SetValue(null, mockHttpClient);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Get(usernameWithSpecialChars);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUrl);
|
||||
// Username should be URL encoded (+ becomes %2B, @ becomes %40)
|
||||
Assert.Contains("test%2Buser%40example.com", capturedUrl);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendAsync_IncludesRequiredHeaders(
|
||||
SutProvider<HibpController> sutProvider,
|
||||
string username,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
|
||||
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
|
||||
.Returns(userId);
|
||||
|
||||
HttpRequestMessage capturedRequest = null;
|
||||
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
|
||||
{
|
||||
capturedRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("")
|
||||
});
|
||||
});
|
||||
|
||||
var mockHttpClient = new HttpClient(mockHandler);
|
||||
_httpClientField.SetValue(null, mockHttpClient);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Get(username);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.True(capturedRequest.Headers.Contains("hibp-api-key"));
|
||||
Assert.True(capturedRequest.Headers.Contains("hibp-client-id"));
|
||||
Assert.True(capturedRequest.Headers.Contains("User-Agent"));
|
||||
Assert.Equal("Bitwarden", capturedRequest.Headers.GetValues("User-Agent").First());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to create a mock HttpClient that returns a specific status code and content
|
||||
/// </summary>
|
||||
private HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
});
|
||||
|
||||
return new HttpClient(mockHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock HttpMessageHandler for testing HttpClient behavior
|
||||
/// </summary>
|
||||
public class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _sendAsync;
|
||||
|
||||
public MockHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsync)
|
||||
{
|
||||
_sendAsync = sendAsync;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return _sendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Billing.Services;
|
||||
using System.Globalization;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
@@ -10,7 +11,8 @@ using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Pricing.Premium;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
|
||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -117,7 +119,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -126,10 +128,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }
|
||||
}
|
||||
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||
Customer = new Customer { Id = customerId },
|
||||
@@ -199,7 +198,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -208,10 +207,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }
|
||||
}
|
||||
Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||
Customer = new Customer
|
||||
@@ -233,7 +229,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
@@ -272,11 +268,12 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount &&
|
||||
o.ProrationBehavior == "none"));
|
||||
|
||||
// Verify the updated invoice email was sent
|
||||
// Verify the updated invoice email was sent with correct price
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
email.ToEmails.Contains("user@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -291,7 +288,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -307,7 +304,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
var organization = new Organization
|
||||
@@ -375,7 +372,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -395,7 +392,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
var organization = new Organization
|
||||
@@ -469,7 +466,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -489,7 +486,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
var organization = new Organization
|
||||
@@ -560,7 +557,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -576,7 +573,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "UK" },
|
||||
TaxExempt = TaxExempt.None
|
||||
};
|
||||
@@ -622,9 +619,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail()
|
||||
public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsTraditionalEmail()
|
||||
{
|
||||
// Arrange
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123" };
|
||||
var customerId = "cus_123";
|
||||
@@ -637,7 +633,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -646,10 +642,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }
|
||||
}
|
||||
Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Customer = new Customer
|
||||
@@ -671,7 +664,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
@@ -708,11 +701,16 @@ public class UpcomingInvoiceHandlerTests
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
// Verify that email was still sent despite the exception
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
email.ToEmails.Contains("user@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
// Verify that traditional email was sent when update fails
|
||||
await _mailService.Received(1).SendInvoiceUpcoming(
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
|
||||
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
|
||||
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
|
||||
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
|
||||
Arg.Is<bool>(b => b == true));
|
||||
|
||||
// Verify renewal email was NOT sent
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -727,7 +725,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -737,12 +735,12 @@ public class UpcomingInvoiceHandlerTests
|
||||
Items = new StripeList<SubscriptionItem>(),
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||
Customer = new Customer { Id = "cus_123" },
|
||||
Metadata = new Dictionary<string, string>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
@@ -784,7 +782,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Free Item" } }
|
||||
Data = [new() { Description = "Free Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -800,7 +798,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
@@ -841,7 +839,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -856,7 +854,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
@@ -885,7 +883,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<bool>());
|
||||
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<UpdatedInvoiceUpcomingMail>());
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -900,7 +898,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
@@ -915,7 +913,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
@@ -964,7 +962,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -977,8 +975,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
@@ -989,7 +987,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
Id = premiumAccessItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -998,7 +996,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1009,8 +1007,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1036,6 +1037,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1045,9 +1048,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1066,7 +1073,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1079,14 +1086,14 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1095,7 +1102,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1156,7 +1163,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1168,14 +1175,14 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1184,7 +1191,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1232,7 +1239,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1244,14 +1251,10 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "si_pm_123",
|
||||
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
Data =
|
||||
[
|
||||
new() { Id = "si_pm_123", Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } }
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1260,7 +1263,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1307,7 +1310,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1319,14 +1322,10 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "si_different_item",
|
||||
Price = new Price { Id = "different-price-id" }
|
||||
}
|
||||
}
|
||||
Data =
|
||||
[
|
||||
new() { Id = "si_different_item", Price = new Price { Id = "different-price-id" } }
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1335,7 +1334,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1378,7 +1377,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError()
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndSendsTraditionalEmail()
|
||||
{
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||
@@ -1393,7 +1392,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1406,14 +1405,14 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1422,7 +1421,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1463,11 +1462,16 @@ public class UpcomingInvoiceHandlerTests
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
// Should still attempt to send email despite the failure
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
// Should send traditional email when update fails
|
||||
await _mailService.Received(1).SendInvoiceUpcoming(
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
|
||||
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
|
||||
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
|
||||
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
|
||||
Arg.Is<bool>(b => b == true));
|
||||
|
||||
// Verify renewal email was NOT sent
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1487,7 +1491,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1500,20 +1504,21 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Id = seatAddOnItemId,
|
||||
Price = new Price { Id = "personal-org-seat-annually" },
|
||||
Quantity = 3
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1522,7 +1527,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1533,8 +1538,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1560,6 +1568,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1569,9 +1579,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1591,7 +1605,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1604,20 +1618,21 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Id = seatAddOnItemId,
|
||||
Price = new Price { Id = "personal-org-seat-annually" },
|
||||
Quantity = 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1626,7 +1641,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1637,8 +1652,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1664,6 +1682,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1673,9 +1693,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1696,7 +1720,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1709,25 +1733,27 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Id = premiumAccessItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Id = seatAddOnItemId,
|
||||
Price = new Price { Id = "personal-org-seat-annually" },
|
||||
Quantity = 2
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1736,7 +1762,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1747,8 +1773,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1776,6 +1805,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1785,9 +1816,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1806,7 +1841,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1819,14 +1854,14 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1835,7 +1870,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
@@ -1877,6 +1912,12 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Plan == familiesPlan.Name &&
|
||||
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1895,7 +1936,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1907,14 +1948,14 @@ public class UpcomingInvoiceHandlerTests
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
@@ -1923,7 +1964,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="MartinCostello.Logging.XUnit" Version="0.5.1" />
|
||||
<PackageReference Include="MartinCostello.Logging.XUnit" Version="0.7.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Rnwood.SmtpServer" Version="3.1.0-ci0868" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,8 +6,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
@@ -95,7 +98,8 @@ public class SavePolicyCommandTests
|
||||
Substitute.For<IPolicyRepository>(),
|
||||
[new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()],
|
||||
Substitute.For<TimeProvider>(),
|
||||
Substitute.For<IPostSavePolicySideEffect>()));
|
||||
Substitute.For<IPostSavePolicySideEffect>(),
|
||||
Substitute.For<IPushNotificationService>()));
|
||||
Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message);
|
||||
}
|
||||
|
||||
@@ -360,6 +364,103 @@ public class SavePolicyCommandTests
|
||||
.ExecuteSideEffectsAsync(default!, default!, default!);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task VNextSaveAsync_SendsPushNotification(
|
||||
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg, false)] Policy currentPolicy)
|
||||
{
|
||||
// Arrange
|
||||
var fakePolicyValidator = new FakeSingleOrgPolicyValidator();
|
||||
fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("");
|
||||
var sutProvider = SutProviderFactory([fakePolicyValidator]);
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
|
||||
.Returns(currentPolicy);
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([currentPolicy]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.VNextSaveAsync(savePolicyModel);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
|
||||
.PushAsync(Arg.Is<PushNotification<SyncPolicyPushNotification>>(p =>
|
||||
p.Type == PushType.PolicyChanged &&
|
||||
p.Target == NotificationTarget.Organization &&
|
||||
p.TargetId == policyUpdate.OrganizationId &&
|
||||
p.ExcludeCurrentContext == false &&
|
||||
p.Payload.OrganizationId == policyUpdate.OrganizationId &&
|
||||
p.Payload.Policy.Id == result.Id &&
|
||||
p.Payload.Policy.Type == policyUpdate.Type &&
|
||||
p.Payload.Policy.Enabled == policyUpdate.Enabled &&
|
||||
p.Payload.Policy.Data == policyUpdate.Data));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_SendsPushNotification([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
|
||||
{
|
||||
var fakePolicyValidator = new FakeSingleOrgPolicyValidator();
|
||||
fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("");
|
||||
var sutProvider = SutProviderFactory([fakePolicyValidator]);
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]);
|
||||
|
||||
var result = await sutProvider.Sut.SaveAsync(policyUpdate);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
|
||||
.PushAsync(Arg.Is<PushNotification<SyncPolicyPushNotification>>(p =>
|
||||
p.Type == PushType.PolicyChanged &&
|
||||
p.Target == NotificationTarget.Organization &&
|
||||
p.TargetId == policyUpdate.OrganizationId &&
|
||||
p.ExcludeCurrentContext == false &&
|
||||
p.Payload.OrganizationId == policyUpdate.OrganizationId &&
|
||||
p.Payload.Policy.Id == result.Id &&
|
||||
p.Payload.Policy.Type == policyUpdate.Type &&
|
||||
p.Payload.Policy.Enabled == policyUpdate.Enabled &&
|
||||
p.Payload.Policy.Data == policyUpdate.Data));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_ExistingPolicy_SendsPushNotificationWithUpdatedPolicy(
|
||||
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg, false)] Policy currentPolicy)
|
||||
{
|
||||
var fakePolicyValidator = new FakeSingleOrgPolicyValidator();
|
||||
fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("");
|
||||
var sutProvider = SutProviderFactory([fakePolicyValidator]);
|
||||
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
|
||||
.Returns(currentPolicy);
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([currentPolicy]);
|
||||
|
||||
var result = await sutProvider.Sut.SaveAsync(policyUpdate);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
|
||||
.PushAsync(Arg.Is<PushNotification<SyncPolicyPushNotification>>(p =>
|
||||
p.Type == PushType.PolicyChanged &&
|
||||
p.Target == NotificationTarget.Organization &&
|
||||
p.TargetId == policyUpdate.OrganizationId &&
|
||||
p.ExcludeCurrentContext == false &&
|
||||
p.Payload.OrganizationId == policyUpdate.OrganizationId &&
|
||||
p.Payload.Policy.Id == result.Id &&
|
||||
p.Payload.Policy.Type == policyUpdate.Type &&
|
||||
p.Payload.Policy.Enabled == policyUpdate.Enabled &&
|
||||
p.Payload.Policy.Data == policyUpdate.Data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new SutProvider with the PolicyValidators registered in the Sut.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Sso;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Auth.UserFeatures.Sso;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class UserSsoOrganizationIdentifierQueryTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_UserHasSingleConfirmedOrganization_ReturnsIdentifier(
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId,
|
||||
Organization organization,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.UserId = userId;
|
||||
organizationUser.OrganizationId = organization.Id;
|
||||
organizationUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
organization.Identifier = "test-org-identifier";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns([organizationUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-org-identifier", result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.GetByIdAsync(organization.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_UserHasNoOrganizations_ReturnsNull(
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns(Array.Empty<OrganizationUser>());
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.DidNotReceive()
|
||||
.GetByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_UserHasMultipleConfirmedOrganizations_ReturnsNull(
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId,
|
||||
OrganizationUser organizationUser1,
|
||||
OrganizationUser organizationUser2)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser1.UserId = userId;
|
||||
organizationUser1.Status = OrganizationUserStatusType.Confirmed;
|
||||
organizationUser2.UserId = userId;
|
||||
organizationUser2.Status = OrganizationUserStatusType.Confirmed;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns([organizationUser1, organizationUser2]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.DidNotReceive()
|
||||
.GetByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserStatusType.Invited)]
|
||||
[BitAutoData(OrganizationUserStatusType.Accepted)]
|
||||
[BitAutoData(OrganizationUserStatusType.Revoked)]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_UserHasOnlyInvitedOrganization_ReturnsNull(
|
||||
OrganizationUserStatusType status,
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.UserId = userId;
|
||||
organizationUser.Status = status;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns([organizationUser]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.DidNotReceive()
|
||||
.GetByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_UserHasMixedStatusOrganizations_OnlyOneConfirmed_ReturnsIdentifier(
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId,
|
||||
Organization organization,
|
||||
OrganizationUser confirmedOrgUser,
|
||||
OrganizationUser invitedOrgUser,
|
||||
OrganizationUser revokedOrgUser)
|
||||
{
|
||||
// Arrange
|
||||
confirmedOrgUser.UserId = userId;
|
||||
confirmedOrgUser.OrganizationId = organization.Id;
|
||||
confirmedOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
|
||||
invitedOrgUser.UserId = userId;
|
||||
invitedOrgUser.Status = OrganizationUserStatusType.Invited;
|
||||
|
||||
revokedOrgUser.UserId = userId;
|
||||
revokedOrgUser.Status = OrganizationUserStatusType.Revoked;
|
||||
|
||||
organization.Identifier = "mixed-status-org";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns(new[] { confirmedOrgUser, invitedOrgUser, revokedOrgUser });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("mixed-status-org", result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.GetByIdAsync(organization.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_OrganizationNotFound_ReturnsNull(
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.UserId = userId;
|
||||
organizationUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns([organizationUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organizationUser.OrganizationId)
|
||||
.Returns((Organization)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.GetByIdAsync(organizationUser.OrganizationId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsNull_ReturnsNull(
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId,
|
||||
Organization organization,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.UserId = userId;
|
||||
organizationUser.OrganizationId = organization.Id;
|
||||
organizationUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
organization.Identifier = null;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns(new[] { organizationUser });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.GetByIdAsync(organization.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsEmpty_ReturnsEmpty(
|
||||
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
|
||||
Guid userId,
|
||||
Organization organization,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.UserId = userId;
|
||||
organizationUser.OrganizationId = organization.Id;
|
||||
organizationUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
organization.Identifier = string.Empty;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(userId)
|
||||
.Returns(new[] { organizationUser });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByUserAsync(userId);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.GetByIdAsync(organization.Id);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,8 @@ If you believe you need to change the version for a valid reason, please discuss
|
||||
LimitCollectionDeletion = true,
|
||||
AllowAdminAccessToAllCollectionItems = true,
|
||||
UseOrganizationDomains = true,
|
||||
UseAdminSponsoredFamilies = false
|
||||
UseAdminSponsoredFamilies = false,
|
||||
UsePhishingBlocker = false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ public class UpdateOrganizationLicenseCommandTests
|
||||
"Hash", "Signature", "SignatureBytes", "InstallationId", "Expires",
|
||||
"ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion",
|
||||
"LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems",
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation") &&
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker") &&
|
||||
// Same property but different name, use explicit mapping
|
||||
org.ExpirationDate == license.Expires));
|
||||
}
|
||||
|
||||
@@ -107,30 +107,6 @@ public class CurrentContextTests
|
||||
Assert.Equal(deviceType, sutProvider.Sut.DeviceType);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildAsync_HttpContext_SetsCloudflareFlags(
|
||||
SutProvider<CurrentContext> sutProvider)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var globalSettings = new Core.Settings.GlobalSettings();
|
||||
sutProvider.Sut.BotScore = null;
|
||||
// Arrange
|
||||
var botScore = 85;
|
||||
httpContext.Request.Headers["X-Cf-Bot-Score"] = botScore.ToString();
|
||||
httpContext.Request.Headers["X-Cf-Worked-Proxied"] = "1";
|
||||
httpContext.Request.Headers["X-Cf-Is-Bot"] = "1";
|
||||
httpContext.Request.Headers["X-Cf-Maybe-Bot"] = "1";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.BuildAsync(httpContext, globalSettings);
|
||||
|
||||
// Assert
|
||||
Assert.True(sutProvider.Sut.CloudflareWorkerProxied);
|
||||
Assert.True(sutProvider.Sut.IsBot);
|
||||
Assert.True(sutProvider.Sut.MaybeBot);
|
||||
Assert.Equal(botScore, sutProvider.Sut.BotScore);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildAsync_HttpContext_SetsClientVersion(
|
||||
SutProvider<CurrentContext> sutProvider)
|
||||
|
||||
@@ -74,7 +74,7 @@ public class SendGridMailDeliveryServiceTests : IDisposable
|
||||
Assert.Equal(mailMessage.HtmlContent, msg.HtmlContent);
|
||||
Assert.Equal(mailMessage.TextContent, msg.PlainTextContent);
|
||||
|
||||
Assert.Contains("type:Cateogry", msg.Categories);
|
||||
Assert.Contains("type:Category", msg.Categories);
|
||||
Assert.Contains(msg.Categories, x => x.StartsWith("env:"));
|
||||
Assert.Contains(msg.Categories, x => x.StartsWith("sender:"));
|
||||
|
||||
|
||||
@@ -44,14 +44,17 @@ internal class CustomValidatorRequestContextCustomization : ICustomization
|
||||
/// <see cref="CustomValidatorRequestContext.TwoFactorRecoveryRequested"/>, and
|
||||
/// <see cref="CustomValidatorRequestContext.SsoRequired" /> should initialize false,
|
||||
/// and are made truthy in context upon evaluation of a request. Do not allow AutoFixture to eagerly make these
|
||||
/// truthy; that is the responsibility of the <see cref="Bit.Identity.IdentityServer.RequestValidators.BaseRequestValidator{T}" />
|
||||
/// truthy; that is the responsibility of the <see cref="Bit.Identity.IdentityServer.RequestValidators.BaseRequestValidator{T}" />.
|
||||
/// ValidationErrorResult and CustomResponse should also be null initially; they are hydrated during the validation process.
|
||||
/// </summary>
|
||||
public void Customize(IFixture fixture)
|
||||
{
|
||||
fixture.Customize<CustomValidatorRequestContext>(composer => composer
|
||||
.With(o => o.RememberMeRequested, false)
|
||||
.With(o => o.TwoFactorRecoveryRequested, false)
|
||||
.With(o => o.SsoRequired, false));
|
||||
.With(o => o.SsoRequired, false)
|
||||
.With(o => o.ValidationErrorResult, () => null)
|
||||
.With(o => o.CustomResponse, () => null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ using Bit.Identity.IdentityServer;
|
||||
using Bit.Identity.IdentityServer.RequestValidators;
|
||||
using Bit.Identity.Test.Wrappers;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -42,6 +43,7 @@ public class BaseRequestValidatorTests
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IDeviceValidator _deviceValidator;
|
||||
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
|
||||
private readonly ISsoRequestValidator _ssoRequestValidator;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly FakeLogger<BaseRequestValidatorTests> _logger;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
@@ -65,6 +67,7 @@ public class BaseRequestValidatorTests
|
||||
_eventService = Substitute.For<IEventService>();
|
||||
_deviceValidator = Substitute.For<IDeviceValidator>();
|
||||
_twoFactorAuthenticationValidator = Substitute.For<ITwoFactorAuthenticationValidator>();
|
||||
_ssoRequestValidator = Substitute.For<ISsoRequestValidator>();
|
||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
_logger = new FakeLogger<BaseRequestValidatorTests>();
|
||||
_currentContext = Substitute.For<ICurrentContext>();
|
||||
@@ -85,6 +88,7 @@ public class BaseRequestValidatorTests
|
||||
_eventService,
|
||||
_deviceValidator,
|
||||
_twoFactorAuthenticationValidator,
|
||||
_ssoRequestValidator,
|
||||
_organizationUserRepository,
|
||||
_logger,
|
||||
_currentContext,
|
||||
@@ -151,6 +155,7 @@ public class BaseRequestValidatorTests
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
|
||||
// 1 -> to pass
|
||||
_sut.isValid = true;
|
||||
|
||||
@@ -162,9 +167,9 @@ public class BaseRequestValidatorTests
|
||||
|
||||
// 4 -> set up device validator to fail
|
||||
requestContext.KnownDevice = false;
|
||||
tokenRequest.GrantType = "password";
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
_deviceValidator
|
||||
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// 5 -> not legacy user
|
||||
@@ -192,6 +197,7 @@ public class BaseRequestValidatorTests
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
|
||||
// 1 -> to pass
|
||||
_sut.isValid = true;
|
||||
|
||||
@@ -203,12 +209,13 @@ public class BaseRequestValidatorTests
|
||||
|
||||
// 4 -> set up device validator to pass
|
||||
_deviceValidator
|
||||
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 5 -> not legacy user
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
@@ -236,6 +243,7 @@ public class BaseRequestValidatorTests
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
|
||||
// 1 -> to pass
|
||||
_sut.isValid = true;
|
||||
|
||||
@@ -262,12 +270,13 @@ public class BaseRequestValidatorTests
|
||||
|
||||
// 4 -> set up device validator to pass
|
||||
_deviceValidator
|
||||
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 5 -> not legacy user
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
@@ -299,6 +308,7 @@ public class BaseRequestValidatorTests
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
|
||||
// 1 -> to pass
|
||||
_sut.isValid = true;
|
||||
|
||||
@@ -319,10 +329,19 @@ public class BaseRequestValidatorTests
|
||||
|
||||
// 2 -> will result to false with no extra configuration
|
||||
// 3 -> set two factor to be required
|
||||
requestContext.User.TwoFactorProviders = "{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"user@test.dev\"}}}";
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
||||
.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||
|
||||
_twoFactorAuthenticationValidator
|
||||
.BuildTwoFactorResultAsync(requestContext.User, null)
|
||||
.Returns(Task.FromResult(new Dictionary<string, object>
|
||||
{
|
||||
{ "TwoFactorProviders", new[] { "0", "1" } },
|
||||
{ "TwoFactorProviders2", new Dictionary<string, object>{{"Email", null}} }
|
||||
}));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
@@ -330,7 +349,10 @@ public class BaseRequestValidatorTests
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
|
||||
// Assert that the auth request was NOT consumed
|
||||
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any<AuthRequest>());
|
||||
await _authRequestRepository.DidNotReceive().ReplaceAsync(authRequest);
|
||||
|
||||
// Assert that the error is for 2fa
|
||||
Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -420,6 +442,7 @@ public class BaseRequestValidatorTests
|
||||
{ "TwoFactorProviders", new[] { "0", "1" } },
|
||||
{ "TwoFactorProviders2", new Dictionary<string, object>() }
|
||||
};
|
||||
|
||||
_twoFactorAuthenticationValidator
|
||||
.BuildTwoFactorResultAsync(user, null)
|
||||
.Returns(Task.FromResult(twoFactorResultDict));
|
||||
@@ -428,6 +451,8 @@ public class BaseRequestValidatorTests
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription);
|
||||
|
||||
// Verify that the failed 2FA email was NOT sent for remember token expiration
|
||||
await _mailService.DidNotReceive()
|
||||
.SendFailedTwoFactorAttemptEmailAsync(Arg.Any<string>(), Arg.Any<TwoFactorProviderType>(),
|
||||
@@ -1243,6 +1268,343 @@ public class BaseRequestValidatorTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is DISABLED, the legacy SSO validation path is used.
|
||||
/// This validates the deprecated RequireSsoLoginAsync method is called and SSO requirement
|
||||
/// is checked using the old PolicyService.AnyPoliciesApplicableToUserAsync approach.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_UsesLegacySsoValidation(
|
||||
bool recoveryCodeFeatureEnabled,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
// SSO is required via legacy path
|
||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
Assert.Equal("SSO authentication is required.", errorResponse.Message);
|
||||
|
||||
// Verify legacy path was used
|
||||
await _policyService.Received(1).AnyPoliciesApplicableToUserAsync(
|
||||
requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
|
||||
// Verify new SsoRequestValidator was NOT called
|
||||
await _ssoRequestValidator.DidNotReceive().ValidateAsync(
|
||||
Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED, the new ISsoRequestValidator is used
|
||||
/// instead of the legacy RequireSsoLoginAsync method.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_UsesNewSsoRequestValidator(
|
||||
bool recoveryCodeFeatureEnabled,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
// Configure SsoRequestValidator to indicate SSO is required
|
||||
_ssoRequestValidator.ValidateAsync(
|
||||
Arg.Any<User>(),
|
||||
Arg.Any<ValidatedTokenRequest>(),
|
||||
Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(false)); // false = SSO required
|
||||
|
||||
// Set up the ValidationErrorResult that SsoRequestValidator would set
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = "sso_required",
|
||||
ErrorDescription = "SSO authentication is required."
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
};
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
|
||||
// Verify new SsoRequestValidator was called
|
||||
await _ssoRequestValidator.Received(1).ValidateAsync(
|
||||
requestContext.User,
|
||||
tokenRequest,
|
||||
requestContext);
|
||||
|
||||
// Verify legacy path was NOT used
|
||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED and SSO is NOT required,
|
||||
/// authentication continues successfully through the new validation path.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_SsoNotRequired_SuccessfulLogin(
|
||||
bool recoveryCodeFeatureEnabled,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
tokenRequest.ClientId = "web";
|
||||
|
||||
// SsoRequestValidator returns true (SSO not required)
|
||||
_ssoRequestValidator.ValidateAsync(
|
||||
Arg.Any<User>(),
|
||||
Arg.Any<ValidatedTokenRequest>(),
|
||||
Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// No 2FA required
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
|
||||
// Device validation passes
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// User is not legacy
|
||||
_userService.IsLegacyUser(Arg.Any<string>()).Returns(false);
|
||||
|
||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
"test-private-key",
|
||||
"test-public-key"
|
||||
)
|
||||
});
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
await _eventService.Received(1).LogUserEventAsync(requestContext.User.Id, EventType.User_LoggedIn);
|
||||
|
||||
// Verify new validator was used
|
||||
await _ssoRequestValidator.Received(1).ValidateAsync(
|
||||
requestContext.User,
|
||||
tokenRequest,
|
||||
requestContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED and SSO validation returns a custom response
|
||||
/// (e.g., with organization identifier), that custom response is properly propagated to the result.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_PropagatesCustomResponse(
|
||||
bool recoveryCodeFeatureEnabled,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
// SsoRequestValidator sets custom response with organization identifier
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = "sso_required",
|
||||
ErrorDescription = "SSO authentication is required."
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") },
|
||||
{ "SsoOrganizationIdentifier", "test-org-identifier" }
|
||||
};
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
|
||||
_ssoRequestValidator.ValidateAsync(
|
||||
Arg.Any<User>(),
|
||||
Arg.Any<ValidatedTokenRequest>(),
|
||||
Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
Assert.NotNull(context.GrantResult.CustomResponse);
|
||||
Assert.Contains("SsoOrganizationIdentifier", context.CustomValidatorRequestContext.CustomResponse);
|
||||
Assert.Equal("test-org-identifier", context.CustomValidatorRequestContext.CustomResponse["SsoOrganizationIdentifier"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is DISABLED and a user with 2FA recovery completes recovery,
|
||||
/// but SSO is required, the legacy error message is returned (without the recovery-specific message).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_RecoveryWithSso_LegacyMessage(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
// Recovery code scenario
|
||||
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
|
||||
tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code";
|
||||
|
||||
// 2FA with recovery
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||
|
||||
_twoFactorAuthenticationValidator
|
||||
.VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code")
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// SSO is required (legacy check)
|
||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
|
||||
// Legacy behavior: recovery-specific message IS shown even without RedirectOnSsoRequired
|
||||
Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message);
|
||||
|
||||
// But legacy validation path was used
|
||||
await _policyService.Received(1).AnyPoliciesApplicableToUserAsync(
|
||||
requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED and recovery code is used for SSO-required user,
|
||||
/// the SsoRequestValidator provides the recovery-specific error message.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_RecoveryWithSso_NewValidatorMessage(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
// Recovery code scenario
|
||||
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
|
||||
tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code";
|
||||
|
||||
// 2FA with recovery
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||
|
||||
_twoFactorAuthenticationValidator
|
||||
.VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code")
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// SsoRequestValidator handles the recovery + SSO scenario
|
||||
requestContext.TwoFactorRecoveryRequested = true;
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = "sso_required",
|
||||
ErrorDescription = "Two-factor recovery has been performed. SSO authentication is required."
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") }
|
||||
};
|
||||
|
||||
_ssoRequestValidator.ValidateAsync(
|
||||
Arg.Any<User>(),
|
||||
Arg.Any<ValidatedTokenRequest>(),
|
||||
Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse["ErrorModel"];
|
||||
Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message);
|
||||
|
||||
// Verify new validator was used
|
||||
await _ssoRequestValidator.Received(1).ValidateAsync(
|
||||
requestContext.User,
|
||||
tokenRequest,
|
||||
Arg.Is<CustomValidatorRequestContext>(ctx => ctx.TwoFactorRecoveryRequested));
|
||||
|
||||
// Verify legacy path was NOT used
|
||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
|
||||
}
|
||||
|
||||
private BaseRequestValidationContextFake CreateContext(
|
||||
ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
|
||||
469
test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs
Normal file
469
test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs
Normal file
@@ -0,0 +1,469 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Sso;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Identity.IdentityServer;
|
||||
using Bit.Identity.IdentityServer.Enums;
|
||||
using Bit.Identity.IdentityServer.RequestValidationConstants;
|
||||
using Bit.Identity.IdentityServer.RequestValidators;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using AuthFixtures = Bit.Identity.Test.AutoFixture;
|
||||
|
||||
namespace Bit.Identity.Test.IdentityServer;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SsoRequestValidatorTests
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OidcConstants.GrantTypes.AuthorizationCode)]
|
||||
[BitAutoData(OidcConstants.GrantTypes.ClientCredentials)]
|
||||
public async void ValidateAsync_GrantTypeIgnoresSsoRequirement_ReturnsTrue(
|
||||
string grantType,
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = grantType;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(context.SsoRequired);
|
||||
Assert.Null(context.ValidationErrorResult);
|
||||
Assert.Null(context.CustomResponse);
|
||||
|
||||
// Should not check policies since grant type allows bypass
|
||||
await sutProvider.GetDependency<IPolicyService>().DidNotReceive()
|
||||
.AnyPoliciesApplicableToUserAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
|
||||
await sutProvider.GetDependency<IPolicyRequirementQuery>().DidNotReceive()
|
||||
.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagEnabled_ReturnsTrue(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(context.SsoRequired);
|
||||
Assert.Null(context.ValidationErrorResult);
|
||||
Assert.Null(context.CustomResponse);
|
||||
|
||||
// Should use the new policy requirement query when feature flag is enabled
|
||||
await sutProvider.GetDependency<IPolicyRequirementQuery>().Received(1).GetAsync<RequireSsoPolicyRequirement>(user.Id);
|
||||
await sutProvider.GetDependency<IPolicyService>().DidNotReceive()
|
||||
.AnyPoliciesApplicableToUserAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagDisabled_ReturnsTrue(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IPolicyService>().AnyPoliciesApplicableToUserAsync(
|
||||
user.Id,
|
||||
PolicyType.RequireSso,
|
||||
OrganizationUserStatusType.Confirmed)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(context.SsoRequired);
|
||||
Assert.Null(context.ValidationErrorResult);
|
||||
Assert.Null(context.CustomResponse);
|
||||
|
||||
// Should use the legacy policy service when feature flag is disabled
|
||||
await sutProvider.GetDependency<IPolicyService>().Received(1).AnyPoliciesApplicableToUserAsync(
|
||||
user.Id,
|
||||
PolicyType.RequireSso,
|
||||
OrganizationUserStatusType.Confirmed);
|
||||
await sutProvider.GetDependency<IPolicyRequirementQuery>().DidNotReceive()
|
||||
.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoRequired_RequirementPolicyFeatureFlagEnabled_ReturnsFalse(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns((string)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.ValidationErrorResult);
|
||||
Assert.True(context.ValidationErrorResult.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error);
|
||||
Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription);
|
||||
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
Assert.True(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.ErrorModel));
|
||||
Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoRequired_RequirementPolicyFeatureFlagDisabled_ReturnsFalse(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IPolicyService>().AnyPoliciesApplicableToUserAsync(
|
||||
user.Id,
|
||||
PolicyType.RequireSso,
|
||||
OrganizationUserStatusType.Confirmed)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns((string)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.ValidationErrorResult);
|
||||
Assert.True(context.ValidationErrorResult.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error);
|
||||
Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription);
|
||||
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
Assert.True(context.CustomResponse.ContainsKey("ErrorModel"));
|
||||
Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier"));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoRequired_TwoFactorRecoveryRequested_ReturnsFalse_WithSpecialMessage(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
context.TwoFactorRecoveryRequested = true;
|
||||
context.TwoFactorRequired = true;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns((string)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.ValidationErrorResult);
|
||||
Assert.True(context.ValidationErrorResult.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error);
|
||||
Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.",
|
||||
context.ValidationErrorResult.ErrorDescription);
|
||||
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
Assert.True(context.CustomResponse.ContainsKey("ErrorModel"));
|
||||
Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier"));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoRequired_TwoFactorRequiredButNotRecovery_ReturnsFalse_WithStandardMessage(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns((string)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.ValidationErrorResult);
|
||||
Assert.True(context.ValidationErrorResult.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error);
|
||||
Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription);
|
||||
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
Assert.True(context.CustomResponse.ContainsKey("ErrorModel"));
|
||||
Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OidcConstants.GrantTypes.Password)]
|
||||
[BitAutoData(OidcConstants.GrantTypes.RefreshToken)]
|
||||
[BitAutoData(CustomGrantTypes.WebAuthn)]
|
||||
public async void ValidateAsync_VariousGrantTypes_SsoRequired_ReturnsFalse(
|
||||
string grantType,
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = grantType;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns((string)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.ValidationErrorResult);
|
||||
Assert.True(context.ValidationErrorResult.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error);
|
||||
Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription);
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_ContextSsoRequiredUpdated_RegardlessOfInitialValue(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
context.SsoRequired = true; // Start with true to ensure it gets updated
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(context.SsoRequired); // Should be updated to false
|
||||
Assert.Null(context.ValidationErrorResult);
|
||||
Assert.Null(context.CustomResponse);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoRequired_WithOrganizationIdentifier_IncludesIdentifierInResponse(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const string orgIdentifier = "test-organization";
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
context.User = user;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns(orgIdentifier);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
Assert.True(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier));
|
||||
Assert.Equal(orgIdentifier, context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier]);
|
||||
|
||||
await sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.Received(1)
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoRequired_NoOrganizationIdentifier_DoesNotIncludeIdentifierInResponse(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
context.User = user;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns((string)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier));
|
||||
|
||||
await sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.Received(1)
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoRequired_EmptyOrganizationIdentifier_DoesNotIncludeIdentifierInResponse(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
context.User = user;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id)
|
||||
.Returns(string.Empty);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.True(context.SsoRequired);
|
||||
Assert.NotNull(context.CustomResponse);
|
||||
Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier));
|
||||
|
||||
await sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.Received(1)
|
||||
.GetSsoOrganizationIdentifierAsync(user.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateAsync_SsoNotRequired_DoesNotCallOrganizationIdentifierQuery(
|
||||
User user,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
SutProvider<SsoRequestValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
request.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
|
||||
.Returns(requirement);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, request, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(context.SsoRequired);
|
||||
|
||||
await sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
|
||||
.DidNotReceive()
|
||||
.GetSsoOrganizationIdentifierAsync(Arg.Any<Guid>());
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class TwoFactorAuthenticationValidatorTests
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmail2faSessionTokenable;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorenabledQuery;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorEnabledQuery;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly TwoFactorAuthenticationValidator _sut;
|
||||
|
||||
@@ -45,7 +45,7 @@ public class TwoFactorAuthenticationValidatorTests
|
||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
_ssoEmail2faSessionTokenable = Substitute.For<IDataProtectorTokenFactory<SsoEmail2faSessionTokenable>>();
|
||||
_twoFactorenabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
|
||||
_twoFactorEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
|
||||
_currentContext = Substitute.For<ICurrentContext>();
|
||||
|
||||
_sut = new TwoFactorAuthenticationValidator(
|
||||
@@ -56,7 +56,7 @@ public class TwoFactorAuthenticationValidatorTests
|
||||
_organizationUserRepository,
|
||||
_organizationRepository,
|
||||
_ssoEmail2faSessionTokenable,
|
||||
_twoFactorenabledQuery,
|
||||
_twoFactorEnabledQuery,
|
||||
_currentContext);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ IBaseRequestValidatorTestWrapper
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
ISsoRequestValidator ssoRequestValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ILogger logger,
|
||||
ICurrentContext currentContext,
|
||||
@@ -73,6 +74,7 @@ IBaseRequestValidatorTestWrapper
|
||||
eventService,
|
||||
deviceValidator,
|
||||
twoFactorAuthenticationValidator,
|
||||
ssoRequestValidator,
|
||||
organizationUserRepository,
|
||||
logger,
|
||||
currentContext,
|
||||
@@ -132,12 +134,17 @@ IBaseRequestValidatorTestWrapper
|
||||
protected override void SetTwoFactorResult(
|
||||
BaseRequestValidationContextFake context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{ }
|
||||
{
|
||||
context.GrantResult = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant, "Two-factor authentication required.", customResponse);
|
||||
}
|
||||
|
||||
protected override void SetValidationErrorResult(
|
||||
BaseRequestValidationContextFake context,
|
||||
CustomValidatorRequestContext requestContext)
|
||||
{ }
|
||||
{
|
||||
context.GrantResult.IsError = true;
|
||||
}
|
||||
|
||||
protected override Task<bool> ValidateContextAsync(
|
||||
BaseRequestValidationContextFake context,
|
||||
|
||||
@@ -93,7 +93,8 @@ public static class OrganizationTestHelpers
|
||||
UseOrganizationDomains = true,
|
||||
UseAdminSponsoredFamilies = true,
|
||||
SyncSeats = false,
|
||||
UseAutomaticUserConfirmation = true
|
||||
UseAutomaticUserConfirmation = true,
|
||||
UsePhishingBlocker = true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -673,7 +673,8 @@ public class OrganizationUserRepositoryTests
|
||||
LimitItemDeletion = false,
|
||||
AllowAdminAccessToAllCollectionItems = false,
|
||||
UseRiskInsights = false,
|
||||
UseAdminSponsoredFamilies = false
|
||||
UseAdminSponsoredFamilies = false,
|
||||
UsePhishingBlocker = false,
|
||||
});
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
|
||||
@@ -225,6 +225,30 @@ public class HubHelpersTest
|
||||
.Group(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendNotificationToHubAsync_PolicyChanged_SentToOrganizationGroup(
|
||||
SutProvider<HubHelpers> sutProvider,
|
||||
SyncPolicyPushNotification notification,
|
||||
string contextId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = ToNotificationJson(notification, PushType.PolicyChanged, contextId);
|
||||
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
|
||||
|
||||
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
|
||||
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
|
||||
.Group($"Organization_{notification.OrganizationId}")
|
||||
.Received(1)
|
||||
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
|
||||
objects.Length == 1 && AssertSyncPolicyPushNotification(notification, objects[0],
|
||||
PushType.PolicyChanged, contextId)),
|
||||
cancellationToken);
|
||||
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
|
||||
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
|
||||
.Group(Arg.Any<string>());
|
||||
}
|
||||
|
||||
private static string ToNotificationJson(object payload, PushType type, string contextId)
|
||||
{
|
||||
var notification = new PushNotificationData<object>(type, payload, contextId);
|
||||
@@ -247,4 +271,20 @@ public class HubHelpersTest
|
||||
expected.ClientType == pushNotificationData.Payload.ClientType &&
|
||||
expected.RevisionDate == pushNotificationData.Payload.RevisionDate;
|
||||
}
|
||||
|
||||
private static bool AssertSyncPolicyPushNotification(SyncPolicyPushNotification expected, object? actual,
|
||||
PushType type, string contextId)
|
||||
{
|
||||
if (actual is not PushNotificationData<SyncPolicyPushNotification> pushNotificationData)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return pushNotificationData.Type == type &&
|
||||
pushNotificationData.ContextId == contextId &&
|
||||
expected.OrganizationId == pushNotificationData.Payload.OrganizationId &&
|
||||
expected.Policy.Id == pushNotificationData.Payload.Policy.Id &&
|
||||
expected.Policy.Type == pushNotificationData.Payload.Policy.Type &&
|
||||
expected.Policy.Enabled == pushNotificationData.Payload.Policy.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandDotNet" Version="7.0.4" />
|
||||
<PackageReference Include="CommandDotNet" Version="7.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
|
||||
/* Introduce new column 'UsePhishingBlocker' not nullable with default of 0 */
|
||||
IF COL_LENGTH('[dbo].[Organization]', 'UsePhishingBlocker') is NULL
|
||||
BEGIN
|
||||
ALTER TABLE [dbo].[Organization] ADD [UsePhishingBlocker] bit NOT NULL CONSTRAINT [DF_Organization_UsePhishingBlocker] default (0)
|
||||
END
|
||||
GO
|
||||
|
||||
/* Update existing stored procedures to include the new column - UsePhishingBlocker */
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Organization_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@Identifier NVARCHAR(50),
|
||||
@Name NVARCHAR(50),
|
||||
@BusinessName NVARCHAR(50),
|
||||
@BusinessAddress1 NVARCHAR(50),
|
||||
@BusinessAddress2 NVARCHAR(50),
|
||||
@BusinessAddress3 NVARCHAR(50),
|
||||
@BusinessCountry VARCHAR(2),
|
||||
@BusinessTaxNumber NVARCHAR(30),
|
||||
@BillingEmail NVARCHAR(256),
|
||||
@Plan NVARCHAR(50),
|
||||
@PlanType TINYINT,
|
||||
@Seats INT,
|
||||
@MaxCollections SMALLINT,
|
||||
@UsePolicies BIT,
|
||||
@UseSso BIT,
|
||||
@UseGroups BIT,
|
||||
@UseDirectory BIT,
|
||||
@UseEvents BIT,
|
||||
@UseTotp BIT,
|
||||
@Use2fa BIT,
|
||||
@UseApi BIT,
|
||||
@UseResetPassword BIT,
|
||||
@SelfHost BIT,
|
||||
@UsersGetPremium BIT,
|
||||
@Storage BIGINT,
|
||||
@MaxStorageGb SMALLINT,
|
||||
@Gateway TINYINT,
|
||||
@GatewayCustomerId VARCHAR(50),
|
||||
@GatewaySubscriptionId VARCHAR(50),
|
||||
@ReferenceData VARCHAR(MAX),
|
||||
@Enabled BIT,
|
||||
@LicenseKey VARCHAR(100),
|
||||
@PublicKey VARCHAR(MAX),
|
||||
@PrivateKey VARCHAR(MAX),
|
||||
@TwoFactorProviders NVARCHAR(MAX),
|
||||
@ExpirationDate DATETIME2(7),
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7),
|
||||
@OwnersNotifiedOfAutoscaling DATETIME2(7),
|
||||
@MaxAutoscaleSeats INT,
|
||||
@UseKeyConnector BIT = 0,
|
||||
@UseScim BIT = 0,
|
||||
@UseCustomPermissions BIT = 0,
|
||||
@UseSecretsManager BIT = 0,
|
||||
@Status TINYINT = 0,
|
||||
@UsePasswordManager BIT = 1,
|
||||
@SmSeats INT = null,
|
||||
@SmServiceAccounts INT = null,
|
||||
@MaxAutoscaleSmSeats INT= null,
|
||||
@MaxAutoscaleSmServiceAccounts INT = null,
|
||||
@SecretsManagerBeta BIT = 0,
|
||||
@LimitCollectionCreation BIT = NULL,
|
||||
@LimitCollectionDeletion BIT = NULL,
|
||||
@AllowAdminAccessToAllCollectionItems BIT = 0,
|
||||
@UseRiskInsights BIT = 0,
|
||||
@LimitItemDeletion BIT = 0,
|
||||
@UseOrganizationDomains BIT = 0,
|
||||
@UseAdminSponsoredFamilies BIT = 0,
|
||||
@SyncSeats BIT = 0,
|
||||
@UseAutomaticUserConfirmation BIT = 0,
|
||||
@UsePhishingBlocker BIT = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[Organization]
|
||||
(
|
||||
[Id],
|
||||
[Identifier],
|
||||
[Name],
|
||||
[BusinessName],
|
||||
[BusinessAddress1],
|
||||
[BusinessAddress2],
|
||||
[BusinessAddress3],
|
||||
[BusinessCountry],
|
||||
[BusinessTaxNumber],
|
||||
[BillingEmail],
|
||||
[Plan],
|
||||
[PlanType],
|
||||
[Seats],
|
||||
[MaxCollections],
|
||||
[UsePolicies],
|
||||
[UseSso],
|
||||
[UseGroups],
|
||||
[UseDirectory],
|
||||
[UseEvents],
|
||||
[UseTotp],
|
||||
[Use2fa],
|
||||
[UseApi],
|
||||
[UseResetPassword],
|
||||
[SelfHost],
|
||||
[UsersGetPremium],
|
||||
[Storage],
|
||||
[MaxStorageGb],
|
||||
[Gateway],
|
||||
[GatewayCustomerId],
|
||||
[GatewaySubscriptionId],
|
||||
[ReferenceData],
|
||||
[Enabled],
|
||||
[LicenseKey],
|
||||
[PublicKey],
|
||||
[PrivateKey],
|
||||
[TwoFactorProviders],
|
||||
[ExpirationDate],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[OwnersNotifiedOfAutoscaling],
|
||||
[MaxAutoscaleSeats],
|
||||
[UseKeyConnector],
|
||||
[UseScim],
|
||||
[UseCustomPermissions],
|
||||
[UseSecretsManager],
|
||||
[Status],
|
||||
[UsePasswordManager],
|
||||
[SmSeats],
|
||||
[SmServiceAccounts],
|
||||
[MaxAutoscaleSmSeats],
|
||||
[MaxAutoscaleSmServiceAccounts],
|
||||
[SecretsManagerBeta],
|
||||
[LimitCollectionCreation],
|
||||
[LimitCollectionDeletion],
|
||||
[AllowAdminAccessToAllCollectionItems],
|
||||
[UseRiskInsights],
|
||||
[LimitItemDeletion],
|
||||
[UseOrganizationDomains],
|
||||
[UseAdminSponsoredFamilies],
|
||||
[SyncSeats],
|
||||
[UseAutomaticUserConfirmation],
|
||||
[UsePhishingBlocker]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@Identifier,
|
||||
@Name,
|
||||
@BusinessName,
|
||||
@BusinessAddress1,
|
||||
@BusinessAddress2,
|
||||
@BusinessAddress3,
|
||||
@BusinessCountry,
|
||||
@BusinessTaxNumber,
|
||||
@BillingEmail,
|
||||
@Plan,
|
||||
@PlanType,
|
||||
@Seats,
|
||||
@MaxCollections,
|
||||
@UsePolicies,
|
||||
@UseSso,
|
||||
@UseGroups,
|
||||
@UseDirectory,
|
||||
@UseEvents,
|
||||
@UseTotp,
|
||||
@Use2fa,
|
||||
@UseApi,
|
||||
@UseResetPassword,
|
||||
@SelfHost,
|
||||
@UsersGetPremium,
|
||||
@Storage,
|
||||
@MaxStorageGb,
|
||||
@Gateway,
|
||||
@GatewayCustomerId,
|
||||
@GatewaySubscriptionId,
|
||||
@ReferenceData,
|
||||
@Enabled,
|
||||
@LicenseKey,
|
||||
@PublicKey,
|
||||
@PrivateKey,
|
||||
@TwoFactorProviders,
|
||||
@ExpirationDate,
|
||||
@CreationDate,
|
||||
@RevisionDate,
|
||||
@OwnersNotifiedOfAutoscaling,
|
||||
@MaxAutoscaleSeats,
|
||||
@UseKeyConnector,
|
||||
@UseScim,
|
||||
@UseCustomPermissions,
|
||||
@UseSecretsManager,
|
||||
@Status,
|
||||
@UsePasswordManager,
|
||||
@SmSeats,
|
||||
@SmServiceAccounts,
|
||||
@MaxAutoscaleSmSeats,
|
||||
@MaxAutoscaleSmServiceAccounts,
|
||||
@SecretsManagerBeta,
|
||||
@LimitCollectionCreation,
|
||||
@LimitCollectionDeletion,
|
||||
@AllowAdminAccessToAllCollectionItems,
|
||||
@UseRiskInsights,
|
||||
@LimitItemDeletion,
|
||||
@UseOrganizationDomains,
|
||||
@UseAdminSponsoredFamilies,
|
||||
@SyncSeats,
|
||||
@UseAutomaticUserConfirmation,
|
||||
@UsePhishingBlocker
|
||||
);
|
||||
END
|
||||
GO
|
||||
|
||||
/* Update existing stored procedures to include the new column - UsePhishingBlocker */
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadAbilities]
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
[Id],
|
||||
[UseEvents],
|
||||
[Use2fa],
|
||||
CASE
|
||||
WHEN [Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}' THEN
|
||||
1
|
||||
ELSE
|
||||
0
|
||||
END AS [Using2fa],
|
||||
[UsersGetPremium],
|
||||
[UseCustomPermissions],
|
||||
[UseSso],
|
||||
[UseKeyConnector],
|
||||
[UseScim],
|
||||
[UseResetPassword],
|
||||
[UsePolicies],
|
||||
[Enabled],
|
||||
[LimitCollectionCreation],
|
||||
[LimitCollectionDeletion],
|
||||
[AllowAdminAccessToAllCollectionItems],
|
||||
[UseRiskInsights],
|
||||
[LimitItemDeletion],
|
||||
[UseOrganizationDomains],
|
||||
[UseAdminSponsoredFamilies],
|
||||
[UseAutomaticUserConfirmation],
|
||||
[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[Organization]
|
||||
END
|
||||
GO
|
||||
|
||||
/* Update existing stored procedures to include the new column - UsePhishingBlocker */
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Organization_Update]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@Identifier NVARCHAR(50),
|
||||
@Name NVARCHAR(50),
|
||||
@BusinessName NVARCHAR(50),
|
||||
@BusinessAddress1 NVARCHAR(50),
|
||||
@BusinessAddress2 NVARCHAR(50),
|
||||
@BusinessAddress3 NVARCHAR(50),
|
||||
@BusinessCountry VARCHAR(2),
|
||||
@BusinessTaxNumber NVARCHAR(30),
|
||||
@BillingEmail NVARCHAR(256),
|
||||
@Plan NVARCHAR(50),
|
||||
@PlanType TINYINT,
|
||||
@Seats INT,
|
||||
@MaxCollections SMALLINT,
|
||||
@UsePolicies BIT,
|
||||
@UseSso BIT,
|
||||
@UseGroups BIT,
|
||||
@UseDirectory BIT,
|
||||
@UseEvents BIT,
|
||||
@UseTotp BIT,
|
||||
@Use2fa BIT,
|
||||
@UseApi BIT,
|
||||
@UseResetPassword BIT,
|
||||
@SelfHost BIT,
|
||||
@UsersGetPremium BIT,
|
||||
@Storage BIGINT,
|
||||
@MaxStorageGb SMALLINT,
|
||||
@Gateway TINYINT,
|
||||
@GatewayCustomerId VARCHAR(50),
|
||||
@GatewaySubscriptionId VARCHAR(50),
|
||||
@ReferenceData VARCHAR(MAX),
|
||||
@Enabled BIT,
|
||||
@LicenseKey VARCHAR(100),
|
||||
@PublicKey VARCHAR(MAX),
|
||||
@PrivateKey VARCHAR(MAX),
|
||||
@TwoFactorProviders NVARCHAR(MAX),
|
||||
@ExpirationDate DATETIME2(7),
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7),
|
||||
@OwnersNotifiedOfAutoscaling DATETIME2(7),
|
||||
@MaxAutoscaleSeats INT,
|
||||
@UseKeyConnector BIT = 0,
|
||||
@UseScim BIT = 0,
|
||||
@UseCustomPermissions BIT = 0,
|
||||
@UseSecretsManager BIT = 0,
|
||||
@Status TINYINT = 0,
|
||||
@UsePasswordManager BIT = 1,
|
||||
@SmSeats INT = null,
|
||||
@SmServiceAccounts INT = null,
|
||||
@MaxAutoscaleSmSeats INT = null,
|
||||
@MaxAutoscaleSmServiceAccounts INT = null,
|
||||
@SecretsManagerBeta BIT = 0,
|
||||
@LimitCollectionCreation BIT = null,
|
||||
@LimitCollectionDeletion BIT = null,
|
||||
@AllowAdminAccessToAllCollectionItems BIT = 0,
|
||||
@UseRiskInsights BIT = 0,
|
||||
@LimitItemDeletion BIT = 0,
|
||||
@UseOrganizationDomains BIT = 0,
|
||||
@UseAdminSponsoredFamilies BIT = 0,
|
||||
@SyncSeats BIT = 0,
|
||||
@UseAutomaticUserConfirmation BIT = 0,
|
||||
@UsePhishingBlocker BIT = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
UPDATE
|
||||
[dbo].[Organization]
|
||||
SET
|
||||
[Identifier] = @Identifier,
|
||||
[Name] = @Name,
|
||||
[BusinessName] = @BusinessName,
|
||||
[BusinessAddress1] = @BusinessAddress1,
|
||||
[BusinessAddress2] = @BusinessAddress2,
|
||||
[BusinessAddress3] = @BusinessAddress3,
|
||||
[BusinessCountry] = @BusinessCountry,
|
||||
[BusinessTaxNumber] = @BusinessTaxNumber,
|
||||
[BillingEmail] = @BillingEmail,
|
||||
[Plan] = @Plan,
|
||||
[PlanType] = @PlanType,
|
||||
[Seats] = @Seats,
|
||||
[MaxCollections] = @MaxCollections,
|
||||
[UsePolicies] = @UsePolicies,
|
||||
[UseSso] = @UseSso,
|
||||
[UseGroups] = @UseGroups,
|
||||
[UseDirectory] = @UseDirectory,
|
||||
[UseEvents] = @UseEvents,
|
||||
[UseTotp] = @UseTotp,
|
||||
[Use2fa] = @Use2fa,
|
||||
[UseApi] = @UseApi,
|
||||
[UseResetPassword] = @UseResetPassword,
|
||||
[SelfHost] = @SelfHost,
|
||||
[UsersGetPremium] = @UsersGetPremium,
|
||||
[Storage] = @Storage,
|
||||
[MaxStorageGb] = @MaxStorageGb,
|
||||
[Gateway] = @Gateway,
|
||||
[GatewayCustomerId] = @GatewayCustomerId,
|
||||
[GatewaySubscriptionId] = @GatewaySubscriptionId,
|
||||
[ReferenceData] = @ReferenceData,
|
||||
[Enabled] = @Enabled,
|
||||
[LicenseKey] = @LicenseKey,
|
||||
[PublicKey] = @PublicKey,
|
||||
[PrivateKey] = @PrivateKey,
|
||||
[TwoFactorProviders] = @TwoFactorProviders,
|
||||
[ExpirationDate] = @ExpirationDate,
|
||||
[CreationDate] = @CreationDate,
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
|
||||
[MaxAutoscaleSeats] = @MaxAutoscaleSeats,
|
||||
[UseKeyConnector] = @UseKeyConnector,
|
||||
[UseScim] = @UseScim,
|
||||
[UseCustomPermissions] = @UseCustomPermissions,
|
||||
[UseSecretsManager] = @UseSecretsManager,
|
||||
[Status] = @Status,
|
||||
[UsePasswordManager] = @UsePasswordManager,
|
||||
[SmSeats] = @SmSeats,
|
||||
[SmServiceAccounts] = @SmServiceAccounts,
|
||||
[MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats,
|
||||
[MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts,
|
||||
[SecretsManagerBeta] = @SecretsManagerBeta,
|
||||
[LimitCollectionCreation] = @LimitCollectionCreation,
|
||||
[LimitCollectionDeletion] = @LimitCollectionDeletion,
|
||||
[AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems,
|
||||
[UseRiskInsights] = @UseRiskInsights,
|
||||
[LimitItemDeletion] = @LimitItemDeletion,
|
||||
[UseOrganizationDomains] = @UseOrganizationDomains,
|
||||
[UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies,
|
||||
[SyncSeats] = @SyncSeats,
|
||||
[UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation,
|
||||
[UsePhishingBlocker] = @UsePhishingBlocker
|
||||
WHERE
|
||||
[Id] = @Id;
|
||||
END
|
||||
@@ -0,0 +1,241 @@
|
||||
/* Adds the UsePhishingBlocker column to the OrganizationUserOrganizationDetailsView view. */
|
||||
CREATE OR ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView]
|
||||
AS
|
||||
SELECT
|
||||
OU.[UserId],
|
||||
OU.[OrganizationId],
|
||||
OU.[Id] OrganizationUserId,
|
||||
O.[Name],
|
||||
O.[Enabled],
|
||||
O.[PlanType],
|
||||
O.[UsePolicies],
|
||||
O.[UseSso],
|
||||
O.[UseKeyConnector],
|
||||
O.[UseScim],
|
||||
O.[UseGroups],
|
||||
O.[UseDirectory],
|
||||
O.[UseEvents],
|
||||
O.[UseTotp],
|
||||
O.[Use2fa],
|
||||
O.[UseApi],
|
||||
O.[UseResetPassword],
|
||||
O.[SelfHost],
|
||||
O.[UsersGetPremium],
|
||||
O.[UseCustomPermissions],
|
||||
O.[UseSecretsManager],
|
||||
O.[Seats],
|
||||
O.[MaxCollections],
|
||||
COALESCE(O.[MaxStorageGbIncreased], O.[MaxStorageGb]) AS [MaxStorageGb],
|
||||
O.[Identifier],
|
||||
OU.[Key],
|
||||
OU.[ResetPasswordKey],
|
||||
O.[PublicKey],
|
||||
O.[PrivateKey],
|
||||
OU.[Status],
|
||||
OU.[Type],
|
||||
SU.[ExternalId] SsoExternalId,
|
||||
OU.[Permissions],
|
||||
PO.[ProviderId],
|
||||
P.[Name] ProviderName,
|
||||
P.[Type] ProviderType,
|
||||
SS.[Enabled] SsoEnabled,
|
||||
SS.[Data] SsoConfig,
|
||||
OS.[FriendlyName] FamilySponsorshipFriendlyName,
|
||||
OS.[LastSyncDate] FamilySponsorshipLastSyncDate,
|
||||
OS.[ToDelete] FamilySponsorshipToDelete,
|
||||
OS.[ValidUntil] FamilySponsorshipValidUntil,
|
||||
OU.[AccessSecretsManager],
|
||||
O.[UsePasswordManager],
|
||||
O.[SmSeats],
|
||||
O.[SmServiceAccounts],
|
||||
O.[LimitCollectionCreation],
|
||||
O.[LimitCollectionDeletion],
|
||||
O.[AllowAdminAccessToAllCollectionItems],
|
||||
O.[UseRiskInsights],
|
||||
O.[LimitItemDeletion],
|
||||
O.[UseAdminSponsoredFamilies],
|
||||
O.[UseOrganizationDomains],
|
||||
OS.[IsAdminInitiated],
|
||||
O.[UseAutomaticUserConfirmation],
|
||||
O.[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[OrganizationUser] OU
|
||||
LEFT JOIN
|
||||
[dbo].[Organization] O ON O.[Id] = OU.[OrganizationId]
|
||||
LEFT JOIN
|
||||
[dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId]
|
||||
LEFT JOIN
|
||||
[dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id]
|
||||
LEFT JOIN
|
||||
[dbo].[Provider] P ON P.[Id] = PO.[ProviderId]
|
||||
LEFT JOIN
|
||||
[dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId]
|
||||
LEFT JOIN
|
||||
[dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id]
|
||||
GO
|
||||
|
||||
/* Updates the ProviderUserProviderOrganizationDetailsView view to include the UsePhishingBlocker column. */
|
||||
CREATE OR ALTER VIEW [dbo].[ProviderUserProviderOrganizationDetailsView]
|
||||
AS
|
||||
SELECT
|
||||
PU.[UserId],
|
||||
PO.[OrganizationId],
|
||||
O.[Name],
|
||||
O.[Enabled],
|
||||
O.[UsePolicies],
|
||||
O.[UseSso],
|
||||
O.[UseKeyConnector],
|
||||
O.[UseScim],
|
||||
O.[UseGroups],
|
||||
O.[UseDirectory],
|
||||
O.[UseEvents],
|
||||
O.[UseTotp],
|
||||
O.[Use2fa],
|
||||
O.[UseApi],
|
||||
O.[UseResetPassword],
|
||||
O.[UseSecretsManager],
|
||||
O.[UsePasswordManager],
|
||||
O.[SelfHost],
|
||||
O.[UsersGetPremium],
|
||||
O.[UseCustomPermissions],
|
||||
O.[Seats],
|
||||
O.[MaxCollections],
|
||||
COALESCE(O.[MaxStorageGbIncreased], O.[MaxStorageGb]) AS [MaxStorageGb],
|
||||
O.[Identifier],
|
||||
PO.[Key],
|
||||
O.[PublicKey],
|
||||
O.[PrivateKey],
|
||||
PU.[Status],
|
||||
PU.[Type],
|
||||
PO.[ProviderId],
|
||||
PU.[Id] ProviderUserId,
|
||||
P.[Name] ProviderName,
|
||||
O.[PlanType],
|
||||
O.[LimitCollectionCreation],
|
||||
O.[LimitCollectionDeletion],
|
||||
O.[AllowAdminAccessToAllCollectionItems],
|
||||
O.[UseRiskInsights],
|
||||
O.[UseAdminSponsoredFamilies],
|
||||
P.[Type] ProviderType,
|
||||
O.[LimitItemDeletion],
|
||||
O.[UseOrganizationDomains],
|
||||
O.[UseAutomaticUserConfirmation],
|
||||
SS.[Enabled] SsoEnabled,
|
||||
SS.[Data] SsoConfig,
|
||||
O.[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[ProviderUser] PU
|
||||
INNER JOIN
|
||||
[dbo].[ProviderOrganization] PO ON PO.[ProviderId] = PU.[ProviderId]
|
||||
INNER JOIN
|
||||
[dbo].[Organization] O ON O.[Id] = PO.[OrganizationId]
|
||||
INNER JOIN
|
||||
[dbo].[Provider] P ON P.[Id] = PU.[ProviderId]
|
||||
LEFT JOIN
|
||||
[dbo].[SsoConfig] SS ON SS.[OrganizationId] = O.[Id]
|
||||
GO
|
||||
|
||||
/* Updates the OrganizationView view to include the UsePhishingBlocker column. */
|
||||
CREATE OR ALTER VIEW [dbo].[OrganizationView]
|
||||
AS
|
||||
SELECT
|
||||
[Id],
|
||||
[Identifier],
|
||||
[Name],
|
||||
[BusinessName],
|
||||
[BusinessAddress1],
|
||||
[BusinessAddress2],
|
||||
[BusinessAddress3],
|
||||
[BusinessCountry],
|
||||
[BusinessTaxNumber],
|
||||
[BillingEmail],
|
||||
[Plan],
|
||||
[PlanType],
|
||||
[Seats],
|
||||
[MaxCollections],
|
||||
[UsePolicies],
|
||||
[UseSso],
|
||||
[UseGroups],
|
||||
[UseDirectory],
|
||||
[UseEvents],
|
||||
[UseTotp],
|
||||
[Use2fa],
|
||||
[UseApi],
|
||||
[UseResetPassword],
|
||||
[SelfHost],
|
||||
[UsersGetPremium],
|
||||
[Storage],
|
||||
COALESCE([MaxStorageGbIncreased], [MaxStorageGb]) AS [MaxStorageGb],
|
||||
[Gateway],
|
||||
[GatewayCustomerId],
|
||||
[GatewaySubscriptionId],
|
||||
[ReferenceData],
|
||||
[Enabled],
|
||||
[LicenseKey],
|
||||
[PublicKey],
|
||||
[PrivateKey],
|
||||
[TwoFactorProviders],
|
||||
[ExpirationDate],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[OwnersNotifiedOfAutoscaling],
|
||||
[MaxAutoscaleSeats],
|
||||
[UseKeyConnector],
|
||||
[UseScim],
|
||||
[UseCustomPermissions],
|
||||
[UseSecretsManager],
|
||||
[Status],
|
||||
[UsePasswordManager],
|
||||
[SmSeats],
|
||||
[SmServiceAccounts],
|
||||
[MaxAutoscaleSmSeats],
|
||||
[MaxAutoscaleSmServiceAccounts],
|
||||
[SecretsManagerBeta],
|
||||
[LimitCollectionCreation],
|
||||
[LimitCollectionDeletion],
|
||||
[LimitItemDeletion],
|
||||
[AllowAdminAccessToAllCollectionItems],
|
||||
[UseRiskInsights],
|
||||
[UseOrganizationDomains],
|
||||
[UseAdminSponsoredFamilies],
|
||||
[SyncSeats],
|
||||
[UseAutomaticUserConfirmation],
|
||||
[UsePhishingBlocker]
|
||||
FROM
|
||||
[dbo].[Organization]
|
||||
GO
|
||||
|
||||
|
||||
--Manually refresh [dbo].[OrganizationUserOrganizationDetailsView]
|
||||
IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetailsView]') IS NOT NULL
|
||||
BEGIN
|
||||
EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetailsView]';
|
||||
END
|
||||
GO
|
||||
|
||||
--Manually refresh [dbo].[ProviderUserProviderOrganizationDetailsView]
|
||||
IF OBJECT_ID('[dbo].[ProviderUserProviderOrganizationDetailsView]') IS NOT NULL
|
||||
BEGIN
|
||||
EXECUTE sp_refreshsqlmodule N'[dbo].[ProviderUserProviderOrganizationDetailsView]';
|
||||
END
|
||||
GO
|
||||
|
||||
--Manually refresh [dbo].[OrganizationView]
|
||||
IF OBJECT_ID('[dbo].[OrganizationView]') IS NOT NULL
|
||||
BEGIN
|
||||
EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationView]';
|
||||
END
|
||||
GO
|
||||
|
||||
--Manually refresh [dbo].[OrganizationCipherDetailsCollectionsView]
|
||||
IF OBJECT_ID('[dbo].[OrganizationCipherDetailsCollectionsView]') IS NOT NULL
|
||||
BEGIN
|
||||
EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationCipherDetailsCollectionsView]';
|
||||
END
|
||||
GO
|
||||
--Manually refresh [dbo].[ProviderOrganizationOrganizationDetailsView]
|
||||
IF OBJECT_ID('[dbo].[ProviderOrganizationOrganizationDetailsView]') IS NOT NULL
|
||||
BEGIN
|
||||
EXECUTE sp_refreshsqlmodule N'[dbo].[ProviderOrganizationOrganizationDetailsView]';
|
||||
END
|
||||
GO
|
||||
@@ -10,7 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandDotNet" Version="7.0.4" />
|
||||
<PackageReference Include="CommandDotNet" Version="7.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
3443
util/MySqlMigrations/Migrations/20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs
generated
Normal file
3443
util/MySqlMigrations/Migrations/20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.MySqlMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class _20251121_00_AddUsePhishingBlockerToOrganization : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "UsePhishingBlocker",
|
||||
table: "Organization",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UsePhishingBlocker",
|
||||
table: "Organization");
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,9 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.Property<bool>("UsePasswordManager")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("UsePhishingBlocker")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("UsePolicies")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user