Merge remote-tracking branch 'origin' into auth/pm-29585/prevent-new-emergency-access

This commit is contained in:
Patrick Pimentel 2026-02-03 10:25:12 -05:00
commit 83b95c0a34
No known key found for this signature in database
GPG Key ID: 4B27FC74C6422186
221 changed files with 17856 additions and 1511 deletions

View File

@ -11,3 +11,7 @@ checkmarx:
filter: "!test"
kics:
filter: "!dev,!.devcontainer"
sca:
filter: "!dev,!.devcontainer"
containers:
filter: "!dev,!.devcontainer"

View File

@ -3,10 +3,12 @@
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
"service": "bitwarden_server",
"workspaceFolder": "/workspace",
"initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
}
"version": "22"
},
"ghcr.io/devcontainers/features/rust:1": {}
},
"mounts": [
{
@ -21,5 +23,27 @@
"extensions": ["ms-dotnettools.csdevkit"]
}
},
"postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh"
"postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh",
"forwardPorts": [1080, 1433, 3306, 5432],
"portsAttributes": {
"default": {
"onAutoForward": "ignore"
},
"1080": {
"label": "Mail Catcher",
"onAutoForward": "notify"
},
"1433": {
"label": "SQL Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "notify"
}
}
}

View File

@ -3,11 +3,46 @@ export DEV_DIR=/workspace/dev
export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev
git config --global --add safe.directory /workspace
if [[ -z "${CODESPACES}" ]]; then
allow_interactive=1
else
echo "Doing non-interactive setup"
allow_interactive=0
fi
get_option() {
# Helper function for reading the value of an environment variable
# primarily but then falling back to an interactive question if allowed
# and lastly falling back to a default value input when either other
# option is available.
name_of_var="$1"
question_text="$2"
default_value="$3"
is_secret="$4"
if [[ -n "${!name_of_var}" ]]; then
# If the env variable they gave us has a value, then use that value
echo "${!name_of_var}"
elif [[ "$allow_interactive" == 1 ]]; then
# If we can be interactive, then use the text they gave us to request input
if [[ "$is_secret" == 1 ]]; then
read -r -s -p "$question_text" response
echo "$response"
else
read -r -p "$question_text" response
echo "$response"
fi
else
# If no environment variable and not interactive, then just give back default value
echo "$default_value"
fi
}
get_installation_id_and_key() {
pushd ./dev >/dev/null || exit
echo "Please enter your installation id and key from https://bitwarden.com/host:"
read -r -p "Installation id: " INSTALLATION_ID
read -r -p "Installation key: " INSTALLATION_KEY
INSTALLATION_ID="$(get_option "INSTALLATION_ID" "Installation id: " "00000000-0000-0000-0000-000000000001")"
INSTALLATION_KEY="$(get_option "INSTALLATION_KEY" "Installation key: " "" 1)"
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
secrets.json.example >secrets.json # create/overwrite secrets.json
@ -30,11 +65,10 @@ configure_other_vars() {
}
one_time_setup() {
read -r -p \
"Would you like to configure your secrets and certificates for the first time?
do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like to configure your secrets and certificates for the first time?
WARNING: This will overwrite any existing secrets.json and certificate files.
Proceed? [y/N] " response
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
Proceed? [y/N] " "n")"
if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "Running one-time setup script..."
sleep 1
get_installation_id_and_key
@ -50,11 +84,4 @@ Proceed? [y/N] " response
fi
}
# main
if [[ -z "${CODESPACES}" ]]; then
one_time_setup
else
# Ignore interactive elements when running in codespaces since they are not supported there
# TODO Write codespaces specific instructions and link here
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
fi

View File

@ -6,10 +6,12 @@
],
"service": "bitwarden_server",
"workspaceFolder": "/workspace",
"initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
}
"version": "22"
},
"ghcr.io/devcontainers/features/rust:1": {}
},
"mounts": [
{
@ -24,9 +26,18 @@
"extensions": ["ms-dotnettools.csdevkit"]
}
},
"onCreateCommand": "bash .devcontainer/internal_dev/onCreateCommand.sh",
"postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh",
"forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002],
"forwardPorts": [
1080, 1433, 3306, 5432, 10000, 10001, 10002,
4000, 4001, 33656, 33657, 44519, 44559,
46273, 46274, 50024, 51822, 51823,
54103, 61840, 61841, 62911, 62912
],
"portsAttributes": {
"default": {
"onAutoForward": "ignore"
},
"1080": {
"label": "Mail Catcher",
"onAutoForward": "notify"
@ -54,6 +65,70 @@
"10002": {
"label": "Azurite Storage Table",
"onAutoForward": "notify"
},
"4000": {
"label": "Api (Cloud)",
"onAutoForward": "notify"
},
"4001": {
"label": "Api (SelfHost)",
"onAutoForward": "notify"
},
"33656": {
"label": "Identity (Cloud)",
"onAutoForward": "notify"
},
"33657": {
"label": "Identity (SelfHost)",
"onAutoForward": "notify"
},
"44519": {
"label": "Billing",
"onAutoForward": "notify"
},
"44559": {
"label": "Scim",
"onAutoForward": "notify"
},
"46273": {
"label": "Events (Cloud)",
"onAutoForward": "notify"
},
"46274": {
"label": "Events (SelfHost)",
"onAutoForward": "notify"
},
"50024": {
"label": "Icons",
"onAutoForward": "notify"
},
"51822": {
"label": "Sso (Cloud)",
"onAutoForward": "notify"
},
"51823": {
"label": "Sso (SelfHost)",
"onAutoForward": "notify"
},
"54103": {
"label": "EventsProcessor",
"onAutoForward": "notify"
},
"61840": {
"label": "Notifications (Cloud)",
"onAutoForward": "notify"
},
"61841": {
"label": "Notifications (SelfHost)",
"onAutoForward": "notify"
},
"62911": {
"label": "Admin (Cloud)",
"onAutoForward": "notify"
},
"62912": {
"label": "Admin (SelfHost)",
"onAutoForward": "notify"
}
}
}

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
export REPO_ROOT="$(git rev-parse --show-toplevel)"
file="$REPO_ROOT/dev/custom-root-ca.crt"
if [ -e "$file" ]; then
echo "Adding custom root CA"
sudo cp "$file" /usr/local/share/ca-certificates/
sudo update-ca-certificates
else
echo "No custom root CA found, skipping..."
fi

View File

@ -108,7 +108,7 @@ Press <Enter> to continue."
fi
run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")"
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
if [[ "$run_mssql_migrations" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "Running migrations..."
sleep 5 # wait for DB container to start
dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING"

7
.github/CODEOWNERS vendored
View File

@ -11,6 +11,9 @@
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
# Scanning tools
.checkmarx/ @bitwarden/team-appsec
## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre
@ -94,9 +97,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
.github/workflows/test-database.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev
**/*Platform* @bitwarden/team-platform-dev
**/.dockerignore @bitwarden/team-platform-dev
**/Dockerfile @bitwarden/team-platform-dev
**/entrypoint.sh @bitwarden/team-platform-dev
# The PushType enum is expected to be editted by anyone without need for Platform review
src/Core/Platform/Push/PushType.cs

View File

@ -21,12 +21,6 @@
commitMessagePrefix: "[deps] AC:",
reviewers: ["team:team-admin-console-dev"],
},
{
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
description: "Admin & SSO npm packages",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"DuoUniversal",
@ -182,6 +176,14 @@
matchUpdateTypes: ["minor"],
addLabels: ["hold"],
},
{
groupName: "Admin and SSO npm dependencies",
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
matchUpdateTypes: ["minor", "patch"],
description: "Admin & SSO npm packages",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
groupName: "EntityFrameworkCore",

View File

@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Verify format
run: dotnet format --verify-no-changes
@ -119,10 +119,10 @@ jobs:
fi
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@ -245,7 +245,7 @@ jobs:
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@ -263,14 +263,14 @@ jobs:
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
@ -294,7 +294,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@ -420,7 +420,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Print environment
run: |

View File

@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Restore tools
run: dotnet tool restore
@ -156,7 +156,7 @@ jobs:
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
- name: Report test results
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@ -183,7 +183,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Print environment
run: |

View File

@ -32,7 +32,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Install rust
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
@ -59,7 +59,7 @@ jobs:
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2026.1.0</Version>
<Version>2026.1.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@ -13,6 +13,10 @@
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<BitIncludeAuthentication>false</BitIncludeAuthentication>
<BitIncludeFeatures>false</BitIncludeFeatures>
</PropertyGroup>
<PropertyGroup>

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Scim</UserSecretsId>

View File

@ -2,7 +2,7 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
@ -45,7 +45,7 @@ public class AccountController : Controller
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IUserRepository _userRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IUserService _userService;
private readonly II18nService _i18nService;
private readonly UserManager<User> _userManager;
@ -67,7 +67,7 @@ public class AccountController : Controller
ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository,
IUserRepository userRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IUserService userService,
II18nService i18nService,
UserManager<User> userManager,
@ -88,7 +88,7 @@ public class AccountController : Controller
_userRepository = userRepository;
_ssoConfigRepository = ssoConfigRepository;
_ssoUserRepository = ssoUserRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_userService = userService;
_i18nService = i18nService;
_userManager = userManager;
@ -687,9 +687,8 @@ public class AccountController : Controller
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
var twoFactorPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy.Enabled)
{
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Sso</UserSecretsId>

View File

@ -8,7 +8,6 @@ using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Bit.Sso.Utilities;
using Duende.IdentityServer.Services;
using Microsoft.IdentityModel.Logging;
using Stripe;
namespace Bit.Sso;
@ -91,20 +90,15 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IWebHostEnvironment environment,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
if (env.IsDevelopment() || globalSettings.SelfHosted)
{
IdentityModelEventSource.ShowPII = true;
}
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
if (!env.IsDevelopment())
if (!environment.IsDevelopment())
{
var uri = new Uri(globalSettings.BaseServiceUri.Sso);
app.Use(async (ctx, next) =>
@ -120,7 +114,7 @@ public class Startup
app.UseForwardedHeaders(globalSettings);
}
if (env.IsDevelopment())
if (environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseCookiePolicy();

View File

@ -34,4 +34,5 @@ RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123
# SETUP_AZURITE=yes
# RUN_MSSQL_MIGRATIONS=yes
# DEV_CERT_PASSWORD=dev_cert_password_here
# DEV_CERT_CONTENTS=base64_encoded_dev_pfx_here (alternative to placing dev.pfx file manually)
# INSTALL_STRIPE_CLI=no

1
dev/.gitignore vendored
View File

@ -18,3 +18,4 @@ signingkey.jwk
# Reverse Proxy Conifg
reverse-proxy.conf
*.crt

View File

@ -39,6 +39,14 @@
},
"licenseDirectory": "<full path to license directory>",
"enableNewDeviceVerification": true,
"enableEmailVerification": true
"enableEmailVerification": true,
"communication": {
"bootstrap": "none",
"ssoCookieVendor": {
"idpLoginUrl": "",
"cookieName": "",
"cookieDomain": ""
}
}
}
}

View File

@ -28,6 +28,7 @@ $projects = @{
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
SeederUtility = "../util/DbSeederUtility"
}
foreach ($key in $projects.keys) {

View File

@ -6,6 +6,6 @@
"msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0",
"Microsoft.Build.Sql": "1.0.0",
"Bitwarden.Server.Sdk": "1.2.0"
"Bitwarden.Server.Sdk": "1.4.0"
}
}

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>

View File

@ -86,7 +86,7 @@ public class UsersController : Controller
return RedirectToAction("Index");
}
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);

View File

@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.ConfigureKestrel(o =>

View File

@ -57,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly ICurrentContext _currentContext;
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
@ -90,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
ICollectionRepository collectionRepository,
IGroupRepository groupRepository,
IUserService userService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
ICurrentContext currentContext,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
@ -123,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
_collectionRepository = collectionRepository;
_groupRepository = groupRepository;
_userService = userService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_currentContext = currentContext;
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
@ -287,14 +287,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
var userId = _userService.GetProperUserId(User);
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
{
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
}
else
{
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
}
return new ListResponseModel<OrganizationUserBulkResponseModel>(
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
@ -357,9 +350,8 @@ public class OrganizationUsersController : BaseAdminConsoleController
return false;
}
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
masterPasswordPolicy.Enabled &&
var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
return useMasterPasswordPolicy;
@ -697,7 +689,16 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task RestoreAsync(Guid orgId, Guid id)
{
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, null));
}
[HttpPut("{id}/restore/vnext")]
[Authorize<ManageUsersRequirement>]
[RequireFeature(FeatureFlagKeys.DefaultUserCollectionRestore)]
public async Task RestoreAsync_vNext(Guid orgId, Guid id, [FromBody] OrganizationUserRestoreRequest request)
{
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, request.DefaultUserCollectionName));
}
[HttpPatch("{id}/restore")]
@ -712,7 +713,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
return await RestoreOrRevokeUsersAsync(orgId, model,
(orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds,
restoringUserId, _userService, model.DefaultUserCollectionName));
}
[HttpPatch("restore")]

View File

@ -48,7 +48,7 @@ public class OrganizationsController : Controller
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
@ -74,7 +74,7 @@ public class OrganizationsController : Controller
public OrganizationsController(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrganizationService organizationService,
IUserService userService,
ICurrentContext currentContext,
@ -99,7 +99,7 @@ public class OrganizationsController : Controller
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_organizationService = organizationService;
_userService = userService;
_currentContext = currentContext;
@ -183,15 +183,14 @@ public class OrganizationsController : Controller
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
}
var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
{
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
}
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
}
[HttpPost("")]

View File

@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@ -43,6 +42,7 @@ public class PoliciesController : Controller
private readonly IUserService _userService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
private readonly IPolicyQuery _policyQuery;
public PoliciesController(IPolicyRepository policyRepository,
IOrganizationUserRepository organizationUserRepository,
@ -54,7 +54,8 @@ public class PoliciesController : Controller
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
IVNextSavePolicyCommand vNextSavePolicyCommand,
IPolicyQuery policyQuery)
{
_policyRepository = policyRepository;
_organizationUserRepository = organizationUserRepository;
@ -68,27 +69,24 @@ public class PoliciesController : Controller
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
_policyQuery = policyQuery;
}
[HttpGet("{type}")]
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
public async Task<PolicyStatusResponseModel> Get(Guid orgId, PolicyType type)
{
if (!await _currentContext.ManagePolicies(orgId))
{
throw new NotFoundException();
}
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null)
{
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
}
var policy = await _policyQuery.RunAsync(orgId, type);
if (policy.Type is PolicyType.SingleOrg)
{
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery);
}
return new PolicyDetailResponseModel(policy);
return new PolicyStatusResponseModel(policy);
}
[HttpGet("")]

View File

@ -2,11 +2,13 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request;
public class OrganizationDomainRequestModel
{
[Required]
[DomainNameValidator]
public string DomainName { get; set; }
}

View File

@ -116,12 +116,17 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
public string ResetPasswordKey { get; set; }
public string MasterPasswordHash { get; set; }
}
#nullable enable
public class OrganizationUserBulkRequestModel
{
[Required, MinLength(1)]
public IEnumerable<Guid> Ids { get; set; }
public IEnumerable<Guid> Ids { get; set; } = new List<Guid>();
[EncryptedString]
[EncryptedStringLength(1000)]
public string? DefaultUserCollectionName { get; set; }
}
#nullable disable
public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel
{

View File

@ -0,0 +1,13 @@
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationUserRestoreRequest
{
/// <summary>
/// This is the encrypted default collection name to be used for restored users if required
/// </summary>
[EncryptedString]
[EncryptedStringLength(1000)]
public string? DefaultUserCollectionName { get; set; }
}

View File

@ -1,19 +1,21 @@
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
public static class PolicyDetailResponses
public static class PolicyStatusResponses
{
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
public static async Task<PolicyStatusResponseModel> GetSingleOrgPolicyStatusResponseAsync(
this PolicyStatus policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
{
if (policy.Type is not PolicyType.SingleOrg)
{
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
}
return new PolicyDetailResponseModel(policy, await CanToggleState());
return new PolicyStatusResponseModel(policy, await CanToggleState());
async Task<bool> CanToggleState()
{
@ -25,5 +27,4 @@ public static class PolicyDetailResponses
return !policy.Enabled;
}
}
}

View File

@ -1,20 +0,0 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyDetailResponseModel : PolicyResponseModel
{
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
{
}
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
{
CanToggleState = canToggleState;
}
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; set; } = true;
}

View File

@ -0,0 +1,33 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyStatusResponseModel : ResponseModel
{
public PolicyStatusResponseModel(PolicyStatus policy, bool canToggleState = true) : base("policy")
{
OrganizationId = policy.OrganizationId;
Type = policy.Type;
if (!string.IsNullOrWhiteSpace(policy.Data))
{
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data) ?? new();
}
Enabled = policy.Enabled;
CanToggleState = canToggleState;
}
public Guid OrganizationId { get; init; }
public PolicyType Type { get; init; }
public Dictionary<string, object> Data { get; init; } = new();
public bool Enabled { get; init; }
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; init; }
}

View File

@ -2,12 +2,16 @@
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
@ -30,6 +34,8 @@ public class MembersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public MembersController(
IOrganizationUserRepository organizationUserRepository,
@ -42,7 +48,9 @@ public class MembersController : Controller
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
@ -55,6 +63,8 @@ public class MembersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
}
/// <summary>
@ -258,4 +268,59 @@ public class MembersController : Controller
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
return new OkResult();
}
/// <summary>
/// Revoke a member's access to an organization.
/// </summary>
/// <param name="id">The ID of the member to be revoked.</param>
[HttpPost("{id}/revoke")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Revoke(Guid id)
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
{
return new NotFoundResult();
}
var request = new RevokeOrganizationUsersRequest(
_currentContext.OrganizationId!.Value,
[id],
new SystemUser(EventSystemUser.PublicApi)
);
var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request);
var result = results.Single();
return result.Result.Match<IActionResult>(
error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
_ => new OkResult()
);
}
/// <summary>
/// Restore a member.
/// </summary>
/// <remarks>
/// Restores a previously revoked member of the organization.
/// </remarks>
/// <param name="id">The identifier of the member to be restored.</param>
[HttpPost("{id}/restore")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Restore(Guid id)
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
{
return new NotFoundResult();
}
await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi);
return new OkResult();
}
}

View File

@ -1,4 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Api</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>

View File

@ -7,7 +7,7 @@ using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Response;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;

View File

@ -1,7 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
using Bit.Core.Jobs;
using Quartz;

View File

@ -1,7 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
using Bit.Core.Jobs;
using Quartz;

View File

@ -6,7 +6,7 @@ using Bit.Api.Models.Response;
using Bit.Api.Models.Response.Organizations;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IFeatureService _featureService;
public OrganizationSponsorshipsController(
@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
IUserService userService,
ICurrentContext currentContext,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IFeatureService featureService)
{
_organizationSponsorshipRepository = organizationSponsorshipRepository;
@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller
_syncSponsorshipsCommand = syncSponsorshipsCommand;
_userService = userService;
_currentContext = currentContext;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_featureService = featureService;
}
@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
@ -108,10 +108,10 @@ public class OrganizationSponsorshipsController : Controller
[SelfHosted(NotSelfHostedOnly = true)]
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName)
{
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
@ -138,9 +138,9 @@ public class OrganizationSponsorshipsController : Controller
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy);
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
isFreeFamilyPolicyEnabled = policy.Enabled;
}
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
@ -165,10 +165,10 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
}
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}

View File

@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator<IEnumerable<
}
// Exclude any account recovery that do not have a key.
existing = existing.Where(o => o.ResetPasswordKey != null).ToList();
existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList();
foreach (var ou in existing)
{

View File

@ -1,7 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Models.Public.Response;
@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel
{ }
public ErrorResponseModel(string errorKey, string errorValue)
: this(errorKey, new string[] { errorValue })
: this(errorKey, [errorValue])
{ }
public ErrorResponseModel(string errorKey, IEnumerable<string> errorValues)
: this(new Dictionary<string, IEnumerable<string>> { { errorKey, errorValues } })
{ }
[JsonConstructor]
public ErrorResponseModel(string message, Dictionary<string, IEnumerable<string>> errors)
{
Message = message;
@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
/// </summary>
/// <example>The request model is invalid.</example>
[Required]
public string Message { get; set; }
public string Message { get; init; }
/// <summary>
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
/// request parameter will include a dictionary key describing that parameter.
/// </summary>
public Dictionary<string, IEnumerable<string>> Errors { get; set; }
public Dictionary<string, IEnumerable<string>>? Errors { get; }
}

View File

@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@ -67,8 +67,9 @@ public class CollectionsController : Controller
{
var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value);
var collectionResponses = collections.Select(c =>
new CollectionResponseModel(c.Item1, c.Item2.Groups));
var collectionResponses = collections
.Where(c => c.Item1.Type != CollectionType.DefaultUserCollection)
.Select(c => new CollectionResponseModel(c.Item1, c.Item2.Groups));
var response = new ListResponseModel<CollectionResponseModel>(collectionResponses);
return new JsonResult(response);

View File

@ -14,7 +14,6 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
using Bit.Core.Auth.Entities;
using Bit.SharedWeb.Health;
using Microsoft.IdentityModel.Logging;
using Microsoft.OpenApi.Models;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
@ -238,8 +237,6 @@ public class Startup
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
IdentityModelEventSource.ShowPII = true;
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@ -74,11 +74,6 @@ public class ImportCiphersController : Controller
throw new BadRequestException("You cannot import this much data at once.");
}
if (model.Ciphers.Any(c => c.ArchivedDate.HasValue))
{
throw new BadRequestException("You cannot import archived items into an organization.");
}
var orgId = new Guid(organizationId);
var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();

View File

@ -239,12 +239,6 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
@ -272,12 +266,6 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);

View File

@ -102,9 +102,17 @@ public class SendRequestModel
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[StringLength(4000)]
[EncryptedString]
[EncryptedStringLength(4000)]
public string Emails { get; set; }
/// <summary>
/// Comma-separated list of email **hashes** that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[StringLength(4000)]
public string EmailHashes { get; set; }
/// <summary>
/// When <see langword="true"/>, send access is disabled.
/// Defaults to <see langword="false"/>.
@ -253,6 +261,7 @@ public class SendRequestModel
// normalize encoding
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
existingSend.Emails = string.Join(",", emails);
existingSend.EmailHashes = EmailHashes;
existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
}

View File

@ -6,6 +6,7 @@ using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -44,6 +45,7 @@ public class SyncController : Controller
private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
public SyncController(
@ -61,6 +63,7 @@ public class SyncController : Controller
IFeatureService featureService,
IApplicationCacheService applicationCacheService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IWebAuthnCredentialRepository webAuthnCredentialRepository,
IUserAccountKeysQuery userAccountKeysQuery)
{
_userService = userService;
@ -77,6 +80,7 @@ public class SyncController : Controller
_featureService = featureService;
_applicationCacheService = applicationCacheService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
_userAccountKeysQuery = userAccountKeysQuery;
}
@ -120,6 +124,9 @@ public class SyncController : Controller
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var webAuthnCredentials = _featureService.IsEnabled(FeatureFlagKeys.PM2035PasskeyUnlock)
? await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id)
: [];
UserAccountKeysData userAccountKeys = null;
// JIT TDE users and some broken/old users may not have a private key.
@ -130,7 +137,7 @@ public class SyncController : Controller
var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials);
return response;
}

View File

@ -6,6 +6,9 @@ using Bit.Api.Models.Response;
using Bit.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Models.Data;
@ -39,7 +42,8 @@ public class SyncResponseModel() : ResponseModel("sync")
IDictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersDict,
bool excludeDomains,
IEnumerable<Policy> policies,
IEnumerable<Send> sends)
IEnumerable<Send> sends,
IEnumerable<WebAuthnCredential> webAuthnCredentials)
: this()
{
Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails,
@ -57,6 +61,16 @@ public class SyncResponseModel() : ResponseModel("sync")
Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
Sends = sends.Select(s => new SendResponseModel(s));
var webAuthnPrfOptions = webAuthnCredentials
.Where(c => c.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
.Select(c => new WebAuthnPrfDecryptionOption(
c.EncryptedPrivateKey,
c.EncryptedUserKey,
c.CredentialId,
[] // transports as empty array
))
.ToArray();
UserDecryption = new UserDecryptionResponseModel
{
MasterPasswordUnlock = user.HasMasterPassword()
@ -72,7 +86,8 @@ public class SyncResponseModel() : ResponseModel("sync")
MasterKeyEncryptedUserKey = user.Key!,
Salt = user.Email.ToLowerInvariant()
}
: null
: null,
WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null
};
}

View File

@ -9,6 +9,7 @@ namespace Bit.Billing.Services.Implementations;
public class StripeEventService(
GlobalSettings globalSettings,
ILogger<StripeEventService> logger,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
@ -148,26 +149,36 @@ public class StripeEventService(
{
var setupIntent = await GetSetupIntent(localStripeEvent);
logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id);
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id);
if (subscriberId == null)
{
logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id);
return null;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id);
if (organization is { GatewayCustomerId: not null })
{
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id);
return organizationCustomer.Metadata;
}
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id);
if (provider is not { GatewayCustomerId: not null })
{
return null;
}
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id);
return providerCustomer.Metadata;
}
}

View File

@ -1,11 +1,21 @@
using System.Text.Json.Serialization;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
public class MasterPasswordPolicyData : IPolicyDataModel
{
/// <summary>
/// Minimum password complexity score (0-4). Null indicates no complexity requirement.
/// </summary>
[JsonPropertyName("minComplexity")]
[Range(0, 4)]
public int? MinComplexity { get; set; }
/// <summary>
/// Minimum password length (12-128). Null indicates no minimum length requirement.
/// </summary>
[JsonPropertyName("minLength")]
[Range(12, 128)]
public int? MinLength { get; set; }
[JsonPropertyName("requireLower")]
public bool? RequireLower { get; set; }

View File

@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
public class PolicyStatus
{
public PolicyStatus(Guid organizationId, PolicyType policyType, Policy? policy = null)
{
OrganizationId = policy?.OrganizationId ?? organizationId;
Data = policy?.Data;
Type = policy?.Type ?? policyType;
Enabled = policy?.Enabled ?? false;
}
public Guid OrganizationId { get; set; }
public PolicyType Type { get; set; }
public bool Enabled { get; set; }
public string? Data { get; set; }
public T GetDataModel<T>() where T : IPolicyDataModel, new()
{
return CoreHelpers.LoadClassFromJsonData<T>(Data);
}
}

View File

@ -1,5 +1,5 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity;
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IUserRepository userRepository,
IMailService mailService,
IEventService eventService,
@ -30,9 +30,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo
}
// Enterprise policy must be enabled
var resetPasswordPolicy =
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}

View File

@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
IPolicyRequirementQuery policyRequirementQuery,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
IUserService userService,
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator
{
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request)
@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
}
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true }
(await policyQuery.RunAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation)).Enabled
&& request.Organization is { UseAutomaticUserConfirmation: true };
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)

View File

@ -4,7 +4,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
public class SendOrganizationInvitesCommand(
IUserRepository userRepository,
ISsoConfigRepository ssoConfigurationRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService) : ISendOrganizationInvitesCommand
@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand(
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
(await policyQuery.RunAsync(organization.Id, PolicyType.RequireSso)).Enabled;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens

View File

@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand
/// </summary>
/// <param name="organizationUser">Revoked user to be restored.</param>
/// <param name="restoringUserId">UserId of the user performing the action.</param>
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string? defaultCollectionName);
/// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
@ -50,5 +50,5 @@ public interface IRestoreOrganizationUserCommand
/// <param name="userService">Passed in from caller to avoid circular dependency</param>
/// <returns>List of organization user Ids and strings. A successful restoration will have an empty string.
/// If an error occurs, the error message will be provided.</returns>
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService, string? defaultCollectionName);
}

View File

@ -31,9 +31,10 @@ public class RestoreOrganizationUserCommand(
IOrganizationService organizationService,
IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand
{
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName)
{
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
{
@ -46,7 +47,7 @@ public class RestoreOrganizationUserCommand(
throw new BadRequestException("Only owners can restore other owners.");
}
await RepositoryRestoreUserAsync(organizationUser);
await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue)
@ -57,7 +58,7 @@ public class RestoreOrganizationUserCommand(
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
{
await RepositoryRestoreUserAsync(organizationUser);
await RepositoryRestoreUserAsync(organizationUser, null); // users stored by a system user will not get a default collection at this point.
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
systemUser);
@ -67,7 +68,7 @@ public class RestoreOrganizationUserCommand(
}
}
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName)
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
@ -104,7 +105,17 @@ public class RestoreOrganizationUserCommand(
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
if (organizationUser.UserId.HasValue
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId
.Value)).State == OrganizationDataOwnershipState.Enabled
&& status == OrganizationUserStatusType.Confirmed
&& featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore)
&& !string.IsNullOrWhiteSpace(defaultCollectionName))
{
await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId,
[organizationUser.Id],
defaultCollectionName);
}
}
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
@ -156,7 +167,8 @@ public class RestoreOrganizationUserCommand(
}
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService,
string defaultCollectionName)
{
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
@ -187,6 +199,9 @@ public class RestoreOrganizationUserCommand(
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers);
var result = new List<Tuple<OrganizationUser, string>>();
var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId))
.ToList();
foreach (var organizationUser in filteredUsers)
{
@ -223,13 +238,24 @@ public class RestoreOrganizationUserCommand(
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue)
{
if (organizationUsersDataOwnershipEnabled.Contains(organizationUser.Id)
&& status == OrganizationUserStatusType.Confirmed
&& !string.IsNullOrWhiteSpace(defaultCollectionName)
&& featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore))
{
await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId,
[organizationUser.Id],
defaultCollectionName);
}
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)

View File

@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
public interface IPolicyQuery
{
/// <summary>
/// Retrieves a summary view of an organization's usage of a policy specified by the <paramref name="policyType"/>.
/// </summary>
/// <remarks>
/// This query is the entrypoint for consumers interested in understanding how a particular <see cref="PolicyType"/>
/// has been applied to an organization; the resultant <see cref="PolicyStatus"/> is not indicative of explicit
/// policy configuration.
/// </remarks>
Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType);
}

View File

@ -0,0 +1,14 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
public class PolicyQuery(IPolicyRepository policyRepository) : IPolicyQuery
{
public async Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType)
{
var dbPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, policyType);
return new PolicyStatus(organizationId, policyType, dbPolicy);
}
}

View File

@ -23,7 +23,7 @@ public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetail
/// Returns true if the user cannot grant emergency access because they are in an
/// auto-confirm organization with status Accepted, Confirmed, or Revoked.
/// </summary>
public bool CannotGrantEmergencyAccess() => policyDetails.Any(p =>
public bool GrantorCannotGrantEmergencyAccess() => policyDetails.Any(p =>
p.OrganizationUserStatus is
OrganizationUserStatusType.Accepted or
OrganizationUserStatusType.Confirmed or
@ -33,7 +33,7 @@ public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetail
/// Returns true if the user cannot be granted emergency access because they are in an
/// auto-confirm organization with status Accepted, Confirmed, or Revoked.
/// </summary>
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(p =>
public bool GranteeCannotBeGrantedEmergencyAccess() => policyDetails.Any(p =>
p.OrganizationUserStatus is
OrganizationUserStatusType.Accepted or
OrganizationUserStatusType.Confirmed or

View File

@ -18,6 +18,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
services.AddScoped<IPolicyQuery, PolicyQuery>();
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
services.AddPolicyValidators();

View File

@ -27,7 +27,6 @@ public interface IOrganizationService
OrganizationUserInvite invite, string externalId);
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId);
Task DeleteSsoUserAsync(Guid userId, Guid? organizationId);
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);

View File

@ -14,7 +14,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Constants;
@ -49,7 +48,7 @@ public class OrganizationService : IOrganizationService
private readonly IEventService _eventService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IPolicyService _policyService;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IGlobalSettings _globalSettings;
@ -76,7 +75,7 @@ public class OrganizationService : IOrganizationService
IEventService eventService,
IApplicationCacheService applicationCacheService,
IStripePaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IPolicyService policyService,
ISsoUserRepository ssoUserRepository,
IGlobalSettings globalSettings,
@ -103,7 +102,7 @@ public class OrganizationService : IOrganizationService
_eventService = eventService;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_policyService = policyService;
_ssoUserRepository = ssoUserRepository;
_globalSettings = globalSettings;
@ -718,32 +717,6 @@ public class OrganizationService : IOrganizationService
return (allOrgUsers, events);
}
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId,
Guid? invitingUserId,
IEnumerable<Guid> organizationUsersId)
{
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId);
_logger.LogUserInviteStateDiagnostics(orgUsers);
var org = await GetOrgById(organizationId);
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var orgUser in orgUsers)
{
if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId)
{
result.Add(Tuple.Create(orgUser, "User invalid."));
continue;
}
await SendInviteAsync(orgUser, org, false);
result.Add(Tuple.Create(orgUser, ""));
}
return result;
}
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
@ -862,9 +835,8 @@ public class OrganizationService : IOrganizationService
}
// Make sure the organization has the policy enabled
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}

View File

@ -1,4 +1,5 @@
using System.Text.Json;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
@ -30,7 +31,8 @@ public static class PolicyDataValidator
switch (policyType)
{
case PolicyType.MasterPassword:
CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
var masterPasswordData = CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
ValidateModel(masterPasswordData, policyType);
break;
case PolicyType.SendOptions:
CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(json);
@ -44,11 +46,24 @@ public static class PolicyDataValidator
}
catch (JsonException ex)
{
var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : "";
var fieldName = !string.IsNullOrEmpty(ex.Path) ? ex.Path.TrimStart('$', '.') : null;
var fieldInfo = !string.IsNullOrEmpty(fieldName) ? $": {fieldName} has an invalid value" : "";
throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}.");
}
}
private static void ValidateModel(object model, PolicyType policyType)
{
var validationContext = new ValidationContext(model);
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(model, validationContext, validationResults, true))
{
var errors = string.Join(", ", validationResults.Select(r => r.ErrorMessage));
throw new BadRequestException($"Invalid data for {policyType} policy: {errors}");
}
}
/// <summary>
/// Validates and deserializes policy metadata based on the policy type.
/// </summary>

View File

@ -45,13 +45,19 @@ public class WebAuthnPrfDecryptionOption
{
public string EncryptedPrivateKey { get; }
public string EncryptedUserKey { get; }
public string CredentialId { get; }
public string[] Transports { get; }
public WebAuthnPrfDecryptionOption(
string encryptedPrivateKey,
string encryptedUserKey)
string encryptedUserKey,
string credentialId,
string[]? transports = null)
{
EncryptedPrivateKey = encryptedPrivateKey;
EncryptedUserKey = encryptedUserKey;
CredentialId = credentialId;
Transports = transports ?? [];
}
}

View File

@ -5,9 +5,9 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
@ -21,7 +21,7 @@ namespace Bit.Core.Auth.Services;
public class SsoConfigService : ISsoConfigService
{
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IEventService _eventService;
@ -29,14 +29,14 @@ public class SsoConfigService : ISsoConfigService
public SsoConfigService(
ISsoConfigRepository ssoConfigRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_ssoConfigRepository = ssoConfigRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_eventService = eventService;
@ -114,14 +114,14 @@ public class SsoConfigService : ISsoConfigService
throw new BadRequestException("Organization cannot use Key Connector.");
}
var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true })
var singleOrgPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.SingleOrg);
if (!singleOrgPolicy.Enabled)
{
throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled.");
}
var ssoPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso);
if (ssoPolicy is not { Enabled: true })
var ssoPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.RequireSso);
if (!ssoPolicy.Enabled)
{
throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled.");
}

View File

@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
@ -21,7 +20,7 @@ using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories;
using Bit.Core.Vault.Services;
namespace Bit.Core.Auth.Services;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess;
public class EmergencyAccessService : IEmergencyAccessService
{
@ -69,7 +68,7 @@ public class EmergencyAccessService : IEmergencyAccessService
_policyRequirementQuery = policyRequirementQuery;
}
public async Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
public async Task<Entities.EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
{
if (!await _userService.CanAccessPremium(grantorUser))
{
@ -86,13 +85,13 @@ public class EmergencyAccessService : IEmergencyAccessService
var requirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(grantorUser.Id);
if (requirement.CannotGrantEmergencyAccess())
if (requirement.GrantorCannotGrantEmergencyAccess())
{
throw new BadRequestException("You cannot invite emergency contacts because you are a member of an organization that uses Automatic User Confirmation.");
}
}
var emergencyAccess = new EmergencyAccess
var emergencyAccess = new Entities.EmergencyAccess
{
GrantorId = grantorUser.Id,
Email = emergencyContactEmail.ToLowerInvariant(),
@ -132,7 +131,7 @@ public class EmergencyAccessService : IEmergencyAccessService
await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser));
}
public async Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService)
public async Task<Entities.EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null)
@ -155,7 +154,7 @@ public class EmergencyAccessService : IEmergencyAccessService
var requirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(granteeUser.Id);
if (requirement.CannotBeGrantedEmergencyAccess())
if (requirement.GranteeCannotBeGrantedEmergencyAccess())
{
throw new BadRequestException("You cannot accept emergency access invitations because you are a member of an organization that uses Automatic User Confirmation.");
}
@ -205,7 +204,7 @@ public class EmergencyAccessService : IEmergencyAccessService
await _emergencyAccessRepository.DeleteAsync(emergencyAccess);
}
public async Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId)
public async Task<Entities.EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted ||
@ -231,7 +230,7 @@ public class EmergencyAccessService : IEmergencyAccessService
return emergencyAccess;
}
public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser)
public async Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser)
{
if (!await _userService.CanAccessPremium(grantorUser))
{
@ -341,7 +340,7 @@ public class EmergencyAccessService : IEmergencyAccessService
}
// TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync
public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser)
public async Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
@ -459,7 +458,7 @@ public class EmergencyAccessService : IEmergencyAccessService
return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId);
}
private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName)
private async Task SendInviteAsync(Entities.EmergencyAccess emergencyAccess, string invitingUsersName)
{
var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours));
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token);
@ -479,7 +478,7 @@ public class EmergencyAccessService : IEmergencyAccessService
*/
//TODO PM-21687: this IsValidRequest() checks the validity based on the granteeUser. There should be a complementary method for the grantorUser
private static bool IsValidRequest(
EmergencyAccess availableAccess,
Entities.EmergencyAccess availableAccess,
User requestingUser,
EmergencyAccessType requestedAccessType)
{

View File

@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
@ -7,7 +6,7 @@ using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Auth.Services;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess;
public interface IEmergencyAccessService
{
@ -20,7 +19,7 @@ public interface IEmergencyAccessService
/// <param name="accessType">Type of emergency access allowed to the emergency contact</param>
/// <param name="waitTime">The amount of time to pass before the invite is auto confirmed</param>
/// <returns>a new Emergency Access object</returns>
Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
Task<Entities.EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
/// <summary>
/// Sends an invite to the emergency contact associated with the emergency access id.
/// </summary>
@ -37,7 +36,7 @@ public interface IEmergencyAccessService
/// <param name="token">the tokenable that was sent via email</param>
/// <param name="userService">service dependency</param>
/// <returns>void</returns>
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
Task<Entities.EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
/// <summary>
/// The creator of the emergency access request can delete the request.
/// </summary>
@ -53,7 +52,7 @@ public interface IEmergencyAccessService
/// <param name="key">The grantor user key encrypted by the grantee public key; grantee.PubicKey(grantor.User.Key)</param>
/// <param name="grantorId">Id of grantor user</param>
/// <returns>emergency access object associated with the Id passed in</returns>
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
Task<Entities.EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
/// <summary>
/// Fetches an emergency access object. The grantor user must own the object being fetched.
/// </summary>
@ -67,7 +66,7 @@ public interface IEmergencyAccessService
/// <param name="emergencyAccess">emergency access entity being updated</param>
/// <param name="grantorUser">grantor user</param>
/// <returns>void</returns>
Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser);
Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser);
/// <summary>
/// Initiates the recovery process. For either Takeover or view. Will send an email to the Grantor User notifying of the initiation.
/// </summary>
@ -107,7 +106,7 @@ public interface IEmergencyAccessService
/// <param name="emergencyAccessId">Id of entity being accessed</param>
/// <param name="granteeUser">grantee user of the emergency access entity</param>
/// <returns>emergency access entity and the grantorUser</returns>
Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
/// <summary>
/// Updates the grantor's password hash and updates the key for the EmergencyAccess entity.
/// </summary>

View File

@ -0,0 +1,14 @@
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
public class EmergencyAccessRemoveGranteesMailView : BaseMailView
{
public required IEnumerable<string> RemovedGranteeNames { get; set; }
public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/";
}
public class EmergencyAccessRemoveGranteesMail : BaseMail<EmergencyAccessRemoveGranteesMailView>
{
public override string Subject { get; set; } = "Emergency contacts removed";
}

View File

@ -36,14 +36,12 @@
.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%; }
.mj-column-per-90 { width:90% !important; max-width: 90%; }
}
</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%; }
.moz-text-html .mj-column-per-90 { width:90% !important; max-width: 90%; }
</style>
@ -58,13 +56,6 @@
}
@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; }
@ -79,11 +70,6 @@
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
</style>
</head>
@ -101,7 +87,7 @@
<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 10px 20px;text-align:center;">
<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;">
@ -148,7 +134,7 @@
<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">
Verify your email to access this Bitwarden Send
</h1></div>
</td>
@ -174,7 +160,7 @@
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
<img alt src="undefined" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
@ -227,7 +213,7 @@
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<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]-->
@ -236,202 +222,24 @@
<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: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:620px;" ><![endif]-->
<td style="direction:ltr;font-size:0px;padding:0px 10px 0px 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" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style 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:1;text-align:left;color:#1B2029;">Your verification code is:</div>
</td>
</tr>
<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:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
</td>
</tr>
<tr>
<td style="font-size:0px;word-break:break-word;">
<div style="height:20px;line-height:20px;">&#8202;</div>
</td>
</tr>
<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:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need
to verify your email again.</div>
</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><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:0px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" 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" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" 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:1;text-align:left;color:#1B2029;"><p>
Bitwarden Send transmits sensitive, temporary information to
others easily and securely. Learn more about
<a href="https://bitwarden.com/help/send" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Bitwarden Send</a>
or
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
to try it today.
</p></div>
</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]-->
<!-- 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:5px 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="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#F3F6F9;background-color:#F3F6F9;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:#F3F6F9;background-color:#F3F6F9;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;">
<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;"><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>
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">The following emergency contacts have been removed from your account:
<ul>
{{#each RemovedGranteeNames}}
<li>{{this}}</li>
{{/each}}
</ul>
Learn more about <a href="{{EmergencyAccessHelpPageUrl}}">emergency access</a>.</div>
</td>
</tr>

View File

@ -0,0 +1,7 @@
The following emergency contacts have been removed from your account:
{{#each RemovedGranteeNames}}
{{this}}
{{/each}}
Learn more about emergency access at {{EmergencyAccessHelpPageUrl}}

View File

@ -1,6 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
@ -27,7 +27,7 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IFeatureService _featureService;
@ -50,7 +50,7 @@ public class RegisterUserCommand : IRegisterUserCommand
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrganizationDomainRepository organizationDomainRepository,
IFeatureService featureService,
IDataProtectionProvider dataProtectionProvider,
@ -65,7 +65,7 @@ public class RegisterUserCommand : IRegisterUserCommand
_globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_organizationDomainRepository = organizationDomainRepository;
_featureService = featureService;
@ -246,9 +246,9 @@ public class RegisterUserCommand : IRegisterUserCommand
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
if (orgUser != null)
{
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId,
var twoFactorPolicy = await _policyQuery.RunAsync(orgUser.OrganizationId,
PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
if (twoFactorPolicy.Enabled)
{
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{

View File

@ -1,11 +1,13 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Caches.Implementations;
public class SetupIntentDistributedCache(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache) : ISetupIntentCache
IDistributedCache distributedCache,
ILogger<SetupIntentDistributedCache> logger) : ISetupIntentCache
{
public async Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId)
{
@ -17,12 +19,13 @@ public class SetupIntentDistributedCache(
{
var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
var value = await distributedCache.GetStringAsync(cacheKey);
if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId))
if (!string.IsNullOrEmpty(value) && Guid.TryParse(value, out var subscriberId))
{
return null;
}
return subscriberId;
}
logger.LogError("Subscriber ID value ({Value}) cached for Setup Intent ({SetupIntentId}) is null or not a valid Guid", value, setupIntentId);
return null;
}
public async Task RemoveSetupIntentForSubscriber(Guid subscriberId)
{

View File

@ -70,10 +70,6 @@ public static class StripeConstants
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string PayPalTransactionId = "btPayPalTransactionId";
public const string PreviousAdditionalStorage = "previous_additional_storage";
public const string PreviousPeriodEndDate = "previous_period_end_date";
public const string PreviousPremiumPriceId = "previous_premium_price_id";
public const string PreviousPremiumUserId = "previous_premium_user_id";
public const string ProviderId = "providerId";
public const string Region = "region";
public const string RetiredBraintreeCustomerId = "btCustomerId_old";

View File

@ -94,6 +94,8 @@ public class UpdatePaymentMethodCommand(
await setupIntentCache.Set(subscriber.Id, setupIntent.Id);
_logger.LogInformation("{Command}: Successfully cached Setup Intent ({SetupIntentId}) for subscriber ({SubscriberID})", CommandName, setupIntent.Id, subscriber.Id);
await UnlinkBraintreeCustomerAsync(customer);
return MaskedPaymentMethod.From(setupIntent);

View File

@ -2,7 +2,6 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@ -96,9 +95,6 @@ public class UpgradePremiumToOrganizationCommand(
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
// Capture the previous additional storage quantity for potential revert
var previousAdditionalStorage = storageItem?.Quantity ?? 0;
if (storageItem != null)
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
@ -133,14 +129,10 @@ public class UpgradePremiumToOrganizationCommand(
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = StripeConstants.ProrationBehavior.None,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId,
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty,
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(),
[StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User
}
};

View File

@ -141,7 +141,7 @@ public static class FeatureFlagKeys
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string DefaultUserCollectionRestore = "pm-30883-my-items-restored-users";
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
@ -149,6 +149,7 @@ public static class FeatureFlagKeys
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
public const string DesktopMigrationMilestone2 = "desktop-ui-migration-milestone-2";
public const string DesktopMigrationMilestone3 = "desktop-ui-migration-milestone-3";
public const string DesktopMigrationMilestone4 = "desktop-ui-migration-milestone-4";
/* Auth Team */
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
@ -159,11 +160,12 @@ public static class FeatureFlagKeys
public const string Otp6Digits = "pm-18612-otp-6-digits";
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock";
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
public const string SafariAccountSwitching = "pm-5594-safari-account-switching";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
@ -174,6 +176,7 @@ public static class FeatureFlagKeys
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string WindowsDesktopAutotype = "windows-desktop-autotype";
public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga";
public const string NotificationUndeterminedCipherScenarioLogic = "undetermined-cipher-scenario-logic";
/* Billing Team */
public const string TrialPayment = "PM-8163-trial-payment";
@ -221,9 +224,10 @@ public static class FeatureFlagKeys
/* Platform Team */
public const string WebPush = "web-push";
public const string IpcChannelFramework = "ipc-channel-framework";
public const string ContentScriptIpcFramework = "content-script-ipc-channel-framework";
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins";
/* Tools Team */
/// <summary>
@ -255,6 +259,8 @@ public static class FeatureFlagKeys
/* DIRT Team */
public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike";
public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging";
public const string EventManagementForHuntress = "event-management-for-huntress";
public const string Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements";
/* UIF Team */
public const string RouterFocusManagement = "router-focus-management";

View File

@ -62,7 +62,7 @@
<PackageReference Include="Otp.NET" Version="1.4.0" />
<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="LaunchDarkly.ServerSdk" Version="8.11.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" />

View File

@ -1,4 +1,7 @@
namespace Bit.Core.KeyManagement.Models.Api.Response;
using System.Text.Json.Serialization;
using Bit.Core.Auth.Models.Api.Response;
namespace Bit.Core.KeyManagement.Models.Api.Response;
public class UserDecryptionResponseModel
{
@ -6,4 +9,10 @@ public class UserDecryptionResponseModel
/// Returns the unlock data when the user has a master password that can be used to decrypt their vault.
/// </summary>
public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; }
/// <summary>
/// Gets or sets the WebAuthn PRF decryption keys.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public WebAuthnPrfDecryptionOption[]? WebAuthnPrfOptions { get; set; }
}

View File

@ -2,17 +2,17 @@
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="display: table; width:100%; padding: 30px; text-align: left;" align="center">
<tr>
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
<b>Here's what that means:</b>
<b>What this means for you</b>
<ul>
<li>Your Bitwarden account is owned by {{OrganizationName}}</li>
<li>Your administrators can delete your account at any time</li>
<li>You cannot leave the organization</li>
<li>Your day-to-day use of Bitwarden remains the same.</li>
<li>Only store work-related items in your {{OrganizationName}} vault.</li>
<li>{{OrganizationName}} admins now manage your account, meaning they can revoke or delete your account.</li>
</ul>
</td>
</tr>
<tr>
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
For more information, please refer to the following help article: <a href="https://bitwarden.com/help/claimed-accounts">Claimed Accounts</a>
For more information, please refer to the following help article: <a href="https://bitwarden.com/help/claimed-accounts">Claimed accounts</a>
</td>
</tr>
</table>

View File

@ -1,7 +1,6 @@
As a member of {{OrganizationName}}, your Bitwarden account is claimed and owned by your organization.
What this means for you:
- Your day-to-day use of Bitwarden remains the same.
- Only store work-related items in your {{OrganizationName}} vault.
- {{OrganizationName}} admins now manage your account, meaning they can revoke or delete your account.
Here's what that means:
- Your administrators can delete your account at any time
- You cannot leave the organization
For more information, please refer to the following help article: Claimed Accounts (https://bitwarden.com/help/claimed-accounts)
For more information, please refer to the following help article: Claimed accounts (https://bitwarden.com/help/claimed-accounts)

View File

@ -1,28 +1,691 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Verify your email to access this Bitwarden Send.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
Your verification code is: <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Token}}</b>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<hr />
{{TheDate}} at {{TheTime}} {{TimeZone}}
<!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%; }
.mj-column-per-90 { width:90% !important; max-width: 90%; }
}
</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%; }
.moz-text-html .mj-column-per-90 { width:90% !important; max-width: 90%; }
</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;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[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 10px 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>
{{/FullHtmlLayout}}
</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">
Verify your email to access this Bitwarden Send
</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/spot-secure-send-round.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 -->
<!--[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:5px 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="#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: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:620px;" ><![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" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style 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:1;text-align:left;color:#1B2029;">Your verification code is:</div>
</td>
</tr>
<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:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
</td>
</tr>
<tr>
<td style="font-size:0px;word-break:break-word;">
<div style="height:20px;line-height:20px;">&#8202;</div>
</td>
</tr>
<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:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need
to verify your email again.</div>
</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><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:0px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" 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" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" 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:1;text-align:left;color:#1B2029;"><p>
Bitwarden Send transmits sensitive, temporary information to
others easily and securely. Learn more about
<a href="https://bitwarden.com/help/send" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Bitwarden Send</a>
or
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
to try it today.
</p></div>
</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]-->
<!-- 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:5px 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="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#F3F6F9;background-color:#F3F6F9;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:#F3F6F9;background-color:#F3F6F9;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:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![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:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
</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:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
</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:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
</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:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
</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:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
</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:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
</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:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
</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; margin-top: 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/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">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

@ -3,7 +3,7 @@ Verify your email to access this Bitwarden Send.
Your verification code is: {{Token}}
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again.
Date : {{TheDate}} at {{TheTime}} {{TimeZone}}
Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today.
{{/BasicTextLayout}}

View File

@ -1,9 +0,0 @@
{{#>BasicTextLayout}}
Verify your email to access this Bitwarden Send.
Your verification code is: {{Token}}
This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again.
Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today.
{{/BasicTextLayout}}

View File

@ -0,0 +1,31 @@
<mjml>
<mj-head>
<mj-include path="../../../../components/head.mjml" />
</mj-head>
<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 0px 20px">
<mj-bw-hero title=""/>
</mj-wrapper>
<!-- Main Content -->
<mj-wrapper padding="0px 20px 0px 20px">
<mj-section background-color="#fff" padding="0px 10px 0px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
The following emergency contacts have been removed from your account:
<ul>
{{#each RemovedGranteeNames}}
<li>{{this}}</li>
{{/each}}
</ul>
Learn more about <a href="{{EmergencyAccessHelpPageUrl}}">emergency access</a>.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<!-- Footer -->
<mj-include path="../../../../components/footer.mjml" />
</mj-body>
</mjml>

View File

@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
@ -30,6 +31,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
private readonly IGroupRepository _groupRepository;
private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
@ -45,6 +47,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
IGroupRepository groupRepository,
IStripePaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
ISsoConfigRepository ssoConfigRepository,
IOrganizationConnectionRepository organizationConnectionRepository,
IServiceAccountRepository serviceAccountRepository,
@ -59,6 +62,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
_groupRepository = groupRepository;
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_ssoConfigRepository = ssoConfigRepository;
_organizationConnectionRepository = organizationConnectionRepository;
_serviceAccountRepository = serviceAccountRepository;
@ -184,9 +188,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
if (!newPlan.HasResetPassword && organization.UseResetPassword)
{
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
"Disable your Password Reset policy.");

View File

@ -209,26 +209,6 @@ public class HandlebarsMailService : IMailService
}
public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
{
var message = CreateDefaultMessage(subject, email);
var requestDateTime = DateTime.UtcNow;
var model = new DefaultEmailOtpViewModel
{
Token = token,
TheDate = requestDateTime.ToLongDateString(),
TheTime = requestDateTime.ToShortTimeString(),
TimeZone = _utcTimeZoneDisplay,
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
message.MetaData.Add("SendGridBypassListManagement", true);
// TODO - PM-25380 change to string constant
message.Category = "SendEmailOtp";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendSendEmailOtpEmailv2Async(string email, string token, string subject)
{
var message = CreateDefaultMessage(subject, email);
var requestDateTime = DateTime.UtcNow;
@ -242,7 +222,7 @@ public class HandlebarsMailService : IMailService
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmailv2", model);
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
message.MetaData.Add("SendGridBypassListManagement", true);
// TODO - PM-25380 change to string constant
message.Category = "SendEmailOtp";
@ -657,11 +637,11 @@ public class HandlebarsMailService : IMailService
return;
MailQueueMessage CreateMessage(string emailAddress, Organization org) =>
new(CreateDefaultMessage($"Your Bitwarden account is claimed by {org.DisplayName()}", emailAddress),
new(CreateDefaultMessage($"Important update to your Bitwarden account", emailAddress),
"AdminConsole.DomainClaimedByOrganization",
new ClaimedDomainUserNotificationViewModel
{
TitleFirst = $"Your Bitwarden account is claimed by {org.DisplayName()}",
TitleFirst = $"Important update to your Bitwarden account",
OrganizationName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false)
});
}

View File

@ -51,17 +51,15 @@ public interface IMailService
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
/// <summary>
/// <see cref="DefaultOtpTokenProviderOptions"/> has a default expiry of 5 minutes so we set the expiry to that value in the view model.
/// Sends OTP code token to the specified email address.
/// will replace <see cref="SendSendEmailOtpEmailAsync"/> when MJML templates are fully accepted.
/// </summary>
/// <param name="email">Email address to send the OTP to</param>
/// <param name="token">Otp code token</param>
/// <param name="subject">subject line of the email</param>
/// <param name="subject">Subject line of the email</param>
/// <returns>Task</returns>
Task SendSendEmailOtpEmailv2Async(string email, string token, string subject);
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);

View File

@ -99,11 +99,6 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendSendEmailOtpEmailv2Async(string email, string token, string subject)
{
return Task.FromResult(0);
}
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{
return Task.FromResult(0);

View File

@ -61,7 +61,7 @@ public class UserService : UserManager<User>, IUserService
private readonly IEventService _eventService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IPolicyService _policyService;
private readonly IFido2 _fido2;
private readonly ICurrentContext _currentContext;
@ -98,7 +98,7 @@ public class UserService : UserManager<User>, IUserService
IEventService eventService,
IApplicationCacheService applicationCacheService,
IStripePaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IPolicyService policyService,
IFido2 fido2,
ICurrentContext currentContext,
@ -139,7 +139,7 @@ public class UserService : UserManager<User>, IUserService
_eventService = eventService;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_policyService = policyService;
_fido2 = fido2;
_currentContext = currentContext;
@ -722,9 +722,8 @@ public class UserService : UserManager<User>, IUserService
}
// Enterprise policy must be enabled
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}

View File

@ -83,7 +83,6 @@ public class GlobalSettings : IGlobalSettings
public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings();
public virtual string DevelopmentDirectory { get; set; }
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5;
public virtual bool EnableEmailVerification { get; set; }
public virtual string KdfDefaultHashKey { get; set; }
@ -93,6 +92,7 @@ public class GlobalSettings : IGlobalSettings
public virtual string SendDefaultHashKey { get; set; }
public virtual string PricingUri { get; set; }
public virtual Fido2Settings Fido2 { get; set; } = new Fido2Settings();
public virtual ICommunicationSettings Communication { get; set; } = new CommunicationSettings();
public string BuildExternalUri(string explicitValue, string name)
{
@ -776,4 +776,17 @@ public class GlobalSettings : IGlobalSettings
{
public HashSet<string> Origins { get; set; }
}
public class CommunicationSettings : ICommunicationSettings
{
public string Bootstrap { get; set; } = "none";
public ISsoCookieVendorSettings SsoCookieVendor { get; set; } = new SsoCookieVendorSettings();
}
public class SsoCookieVendorSettings : ISsoCookieVendorSettings
{
public string IdpLoginUrl { get; set; }
public string CookieName { get; set; }
public string CookieDomain { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Settings;
public interface ICommunicationSettings
{
string Bootstrap { get; set; }
ISsoCookieVendorSettings SsoCookieVendor { get; set; }
}

View File

@ -29,4 +29,5 @@ public interface IGlobalSettings
IWebPushSettings WebPush { get; set; }
GlobalSettings.EventLoggingSettings EventLogging { get; set; }
GlobalSettings.WebAuthnSettings WebAuthn { get; set; }
ICommunicationSettings Communication { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Settings;
public interface ISsoCookieVendorSettings
{
string IdpLoginUrl { get; set; }
string CookieName { get; set; }
string CookieDomain { get; set; }
}

View File

@ -81,6 +81,15 @@ public class Send : ITableObject<Guid>
[MaxLength(4000)]
public string? Emails { get; set; }
/// <summary>
/// Comma-separated list of email **hashes** for OTP authentication.
/// </summary>
/// <remarks>
/// This field is mutually exclusive with <see cref="Password" />
/// </remarks>
[MaxLength(4000)]
public string? EmailHashes { get; set; }
/// <summary>
/// The send becomes unavailable to API callers when
/// <see cref="AccessCount"/> &gt;= <see cref="MaxAccessCount"/>.

View File

@ -74,7 +74,13 @@ public class ImportCiphersCommand : IImportCiphersCommand
if (cipher.UserId.HasValue && cipher.Favorite)
{
cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":\"true\"}}";
cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}";
}
if (cipher.UserId.HasValue && cipher.ArchivedDate.HasValue)
{
cipher.Archives = $"{{\"{cipher.UserId.Value.ToString().ToUpperInvariant()}\":\"" +
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
}
}
@ -135,10 +141,16 @@ public class ImportCiphersCommand : IImportCiphersCommand
}
}
// Init. ids for ciphers
foreach (var cipher in ciphers)
{
// Init. ids for ciphers
cipher.SetNewId();
if (cipher.ArchivedDate.HasValue)
{
cipher.Archives = $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":\"" +
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
}
}
var organizationCollectionsIds = (await _collectionRepository.GetManyByOrganizationIdAsync(org.Id)).Select(c => c.Id).ToList();

View File

@ -44,7 +44,7 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
/// <summary>
/// Create a send claim by requesting a one time password (OTP) confirmation code.
/// </summary>
/// <param name="Emails">
/// The list of email addresses permitted access to the send.
/// <param name="EmailHashes">
/// The list of email address **hashes** permitted access to the send.
/// </param>
public record EmailOtp(string[] Emails) : SendAuthenticationMethod;
public record EmailOtp(string[] EmailHashes) : SendAuthenticationMethod;

View File

@ -37,8 +37,11 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
SendAuthenticationMethod method = send switch
{
null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
var s when s.Disabled => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE,
var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE,
var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE,
var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};
@ -46,9 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
return method;
}
private EmailOtp emailOtp(string emails)
private static EmailOtp EmailOtp(string? emailHashes)
{
var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (string.IsNullOrWhiteSpace(emailHashes))
{
return new EmailOtp([]);
}
var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new EmailOtp(list);
}
}

View File

@ -0,0 +1,64 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
namespace Bit.Core.Utilities;
/// <summary>
/// https://bitwarden.atlassian.net/browse/VULN-376
/// Domain names are vulnerable to XSS attacks if not properly validated.
/// Domain names can contain letters, numbers, dots, and hyphens.
/// Domain names maybe internationalized (IDN) and contain unicode characters.
/// </summary>
public class DomainNameValidatorAttribute : ValidationAttribute
{
// RFC 1123 compliant domain name regex
// - Allows alphanumeric characters and hyphens
// - Cannot start or end with a hyphen
// - Each label (part between dots) must be 1-63 characters
// - Total length should not exceed 253 characters
// - Supports internationalized domain names (IDN) - which is why this regex includes unicode ranges
private static readonly Regex _domainNameRegex = new(
@"^(?:[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF](?:[a-zA-Z0-9\-\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{0,61}[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])?\.)*[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF](?:[a-zA-Z0-9\-\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{0,61}[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase
);
public DomainNameValidatorAttribute()
: base("The {0} field is not a valid domain name.")
{ }
public override bool IsValid(object? value)
{
if (value == null)
{
return true; // Use [Required] for null checks
}
var domainName = value.ToString();
if (string.IsNullOrWhiteSpace(domainName))
{
return false;
}
// Reject if contains any whitespace (including leading/trailing spaces, tabs, newlines)
if (domainName.Any(char.IsWhiteSpace))
{
return false;
}
// Check length constraints
if (domainName.Length > 253)
{
return false;
}
// Check for control characters or other dangerous characters
if (domainName.Any(c => char.IsControl(c) || c == '<' || c == '>' || c == '"' || c == '\'' || c == '&'))
{
return false;
}
// Validate against domain name regex
return _domainNameRegex.IsMatch(domainName);
}
}

Some files were not shown because too many files have changed in this diff Show More