1
0
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:
Bernd Schoolmann
2025-12-02 15:49:55 +01:00
106 changed files with 14956 additions and 394 deletions

View File

@@ -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:

View File

@@ -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",

View File

@@ -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',

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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">

View File

@@ -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);
}
}
}

View File

@@ -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; }

View File

@@ -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

View File

@@ -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>

View File

@@ -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)
{

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
bool UsePhishingBlocker { get; set; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -154,6 +154,7 @@ public class SelfHostedOrganizationDetails : Organization
Status = Status,
UseRiskInsights = UseRiskInsights,
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UsePhishingBlocker = UsePhishingBlocker,
};
}
}

View File

@@ -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; }
}

View File

@@ -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

View File

@@ -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
}
});
}

View File

@@ -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
}
});
}

View File

@@ -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.")]

View File

@@ -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,
};
}

View 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);
}

View 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;
}
}

View File

@@ -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>();
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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}");

View File

@@ -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";

View File

@@ -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;

View File

@@ -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; }

View File

@@ -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" />

View File

@@ -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"

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"; }
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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"; }
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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}}

View File

@@ -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; }
}

View File

@@ -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,
}

View File

@@ -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.";
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -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,

View File

@@ -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>();

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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
});
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -28,7 +28,8 @@ BEGIN
[LimitItemDeletion],
[UseOrganizationDomains],
[UseAdminSponsoredFamilies],
[UseAutomaticUserConfirmation]
[UseAutomaticUserConfirmation],
[UsePhishingBlocker]
FROM
[dbo].[Organization]
END

View File

@@ -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

View File

@@ -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)
);

View File

@@ -55,7 +55,8 @@ SELECT
O.[UseAdminSponsoredFamilies],
O.[UseOrganizationDomains],
OS.[IsAdminInitiated],
O.[UseAutomaticUserConfirmation]
O.[UseAutomaticUserConfirmation],
O.[UsePhishingBlocker]
FROM
[dbo].[OrganizationUser] OU
LEFT JOIN

View File

@@ -61,6 +61,7 @@ SELECT
[UseOrganizationDomains],
[UseAdminSponsoredFamilies],
[SyncSeats],
[UseAutomaticUserConfirmation]
[UseAutomaticUserConfirmation],
[UsePhishingBlocker]
FROM
[dbo].[Organization]

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View 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);
}
}

View File

@@ -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" }
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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,
};
}

View File

@@ -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));
}

View File

@@ -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)

View File

@@ -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:"));

View File

@@ -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));
}
}

View File

@@ -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,

View 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>());
}
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -93,7 +93,8 @@ public static class OrganizationTestHelpers
UseOrganizationDomains = true,
UseAdminSponsoredFamilies = true,
SyncSeats = false,
UseAutomaticUserConfirmation = true
UseAutomaticUserConfirmation = true,
UsePhishingBlocker = true,
});
}

View File

@@ -673,7 +673,8 @@ public class OrganizationUserRepositoryTests
LimitItemDeletion = false,
AllowAdminAccessToAllCollectionItems = false,
UseRiskInsights = false,
UseAdminSponsoredFamilies = false
UseAdminSponsoredFamilies = false,
UsePhishingBlocker = false,
});
var organizationDomain = new OrganizationDomain

View File

@@ -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;
}
}

View File

@@ -16,7 +16,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandDotNet" Version="7.0.4" />
<PackageReference Include="CommandDotNet" Version="7.0.5" />
</ItemGroup>
</Project>

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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");
}
}

View File

@@ -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