mirror of
https://github.com/bitwarden/server
synced 2026-01-15 15:03:34 +00:00
Merge branch 'main' into renovate/microsoft.extensions.caching.cosmos-1.x
This commit is contained in:
@@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method
|
||||
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.any_async_methods.required_modifiers = async
|
||||
|
||||
dotnet_naming_style.end_in_async.required_prefix =
|
||||
dotnet_naming_style.end_in_async.required_prefix =
|
||||
dotnet_naming_style.end_in_async.required_suffix = Async
|
||||
dotnet_naming_style.end_in_async.capitalization = pascal_case
|
||||
dotnet_naming_style.end_in_async.word_separator =
|
||||
dotnet_naming_style.end_in_async.word_separator =
|
||||
|
||||
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
|
||||
dotnet_diagnostic.CS0618.severity = suggestion
|
||||
@@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion
|
||||
# Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
|
||||
# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304
|
||||
dotnet_diagnostic.CA1304.severity = warning
|
||||
|
||||
# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305
|
||||
dotnet_diagnostic.CA1305.severity = warning
|
||||
|
||||
# CSharp code style settings:
|
||||
[*.cs]
|
||||
# Prefer "var" everywhere
|
||||
|
||||
35
.github/workflows/build.yml
vendored
35
.github/workflows/build.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
@@ -39,8 +39,7 @@ jobs:
|
||||
build-artifacts:
|
||||
name: Build Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- lint
|
||||
needs: lint
|
||||
outputs:
|
||||
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
|
||||
permissions:
|
||||
@@ -120,7 +119,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
@@ -271,7 +270,7 @@ jobs:
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
@@ -295,7 +294,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
@@ -401,8 +400,7 @@ jobs:
|
||||
build-mssqlmigratorutility:
|
||||
name: Build MSSQL migrator utility
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- lint
|
||||
needs: lint
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -422,7 +420,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -452,14 +450,13 @@ jobs:
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||
if-no-files-found: error
|
||||
|
||||
self-host-build:
|
||||
name: Trigger self-host build
|
||||
bitwarden-lite-build:
|
||||
name: Trigger Bitwarden lite build
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build-artifacts
|
||||
needs: build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
@@ -505,11 +502,10 @@ jobs:
|
||||
});
|
||||
|
||||
trigger-k8s-deploy:
|
||||
name: Trigger k8s deploy
|
||||
name: Trigger K8s deploy
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build-artifacts
|
||||
needs: build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
@@ -539,7 +535,7 @@ jobs:
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: devops
|
||||
|
||||
- name: Trigger k8s deploy
|
||||
- name: Trigger K8s deploy
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -557,8 +553,7 @@ jobs:
|
||||
|
||||
setup-ephemeral-environment:
|
||||
name: Setup Ephemeral Environment
|
||||
needs:
|
||||
- build-artifacts
|
||||
needs: build-artifacts
|
||||
if: |
|
||||
needs.build-artifacts.outputs.has_secrets == 'true'
|
||||
&& github.event_name == 'pull_request'
|
||||
@@ -581,7 +576,7 @@ jobs:
|
||||
- build-artifacts
|
||||
- upload
|
||||
- build-mssqlmigratorutility
|
||||
- self-host-build
|
||||
- bitwarden-lite-build
|
||||
- trigger-k8s-deploy
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
71
.github/workflows/cleanup-after-pr.yml
vendored
71
.github/workflows/cleanup-after-pr.yml
vendored
@@ -1,71 +0,0 @@
|
||||
name: Container registry cleanup
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
name: Remove branch-specific Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to Azure ACR
|
||||
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
|
||||
|
||||
########## Remove Docker images ##########
|
||||
- name: Remove the Docker image from ACR
|
||||
env:
|
||||
REF: ${{ github.event.pull_request.head.ref }}
|
||||
SERVICES: |
|
||||
services:
|
||||
- Admin
|
||||
- Api
|
||||
- Attachments
|
||||
- Events
|
||||
- EventsProcessor
|
||||
- Icons
|
||||
- Identity
|
||||
- K8S-Proxy
|
||||
- MsSql
|
||||
- Nginx
|
||||
- Notifications
|
||||
- Server
|
||||
- Setup
|
||||
- Sso
|
||||
run: |
|
||||
for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - )
|
||||
do
|
||||
SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}')
|
||||
IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
|
||||
|
||||
echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG"
|
||||
TAG_EXISTS=$(
|
||||
az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \
|
||||
| jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)'
|
||||
)
|
||||
|
||||
if [[ "$TAG_EXISTS" == "true" ]]; then
|
||||
echo "[*] Tag exists. Removing tag"
|
||||
az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes
|
||||
else
|
||||
echo "[*] Tag does not exist. No action needed"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Log out of Docker
|
||||
run: docker logout
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
2
.github/workflows/review-code.yml
vendored
2
.github/workflows/review-code.yml
vendored
@@ -2,7 +2,7 @@ name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
|
||||
4
.github/workflows/test-database.yml
vendored
4
.github/workflows/test-database.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -32,10 +32,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
|
||||
- name: Install rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
|
||||
84
.vscode/launch.json
vendored
84
.vscode/launch.json
vendored
@@ -69,6 +69,28 @@
|
||||
"preLaunchTask": "buildFullServer",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "Full Server with Seeder API",
|
||||
"configurations": [
|
||||
"run-Admin",
|
||||
"run-API",
|
||||
"run-Events",
|
||||
"run-EventsProcessor",
|
||||
"run-Identity",
|
||||
"run-Sso",
|
||||
"run-Icons",
|
||||
"run-Billing",
|
||||
"run-Notifications",
|
||||
"run-SeederAPI"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 6
|
||||
},
|
||||
"preLaunchTask": "buildFullServerWithSeederApi",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "Self Host: Bit",
|
||||
"configurations": [
|
||||
@@ -204,6 +226,17 @@
|
||||
},
|
||||
"preLaunchTask": "buildSso",
|
||||
},
|
||||
{
|
||||
"name": "Seeder API",
|
||||
"configurations": [
|
||||
"run-SeederAPI"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "cloud",
|
||||
},
|
||||
"preLaunchTask": "buildSeederAPI",
|
||||
},
|
||||
{
|
||||
"name": "Admin Self Host",
|
||||
"configurations": [
|
||||
@@ -270,6 +303,17 @@
|
||||
},
|
||||
"preLaunchTask": "buildSso",
|
||||
},
|
||||
{
|
||||
"name": "Seeder API Self Host",
|
||||
"configurations": [
|
||||
"run-SeederAPI-SelfHost"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "self-host",
|
||||
},
|
||||
"preLaunchTask": "buildSeederAPI",
|
||||
}
|
||||
],
|
||||
"configurations": [
|
||||
// Configurations represent run-only scenarios so that they can be used in multiple compounds
|
||||
@@ -311,6 +355,25 @@
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-SeederAPI",
|
||||
"presentation": {
|
||||
"hidden": true,
|
||||
},
|
||||
"requireExactSource": true,
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/util/SeederApi",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-Billing",
|
||||
"presentation": {
|
||||
@@ -488,6 +551,27 @@
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-SeederAPI-SelfHost",
|
||||
"presentation": {
|
||||
"hidden": true,
|
||||
},
|
||||
"requireExactSource": true,
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/util/SeederApi",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "http://localhost:5048",
|
||||
"developSelfHosted": "true",
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run-Admin-SelfHost",
|
||||
"presentation": {
|
||||
|
||||
69
.vscode/tasks.json
vendored
69
.vscode/tasks.json
vendored
@@ -43,6 +43,21 @@
|
||||
"label": "buildFullServer",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
"buildAPI",
|
||||
"buildEventsProcessor",
|
||||
"buildIdentity",
|
||||
"buildSso",
|
||||
"buildIcons",
|
||||
"buildBilling",
|
||||
"buildNotifications"
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "buildFullServerWithSeederApi",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
"buildAPI",
|
||||
@@ -52,6 +67,7 @@
|
||||
"buildIcons",
|
||||
"buildBilling",
|
||||
"buildNotifications",
|
||||
"buildSeederAPI"
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -89,6 +105,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -102,6 +121,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -115,6 +137,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -128,6 +153,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -141,6 +169,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -154,6 +185,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -167,6 +201,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
@@ -180,6 +217,29 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "buildSeederAPI",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/util/SeederApi/SeederApi.csproj",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
@@ -197,6 +257,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
@@ -214,6 +277,9 @@
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
@@ -224,6 +290,9 @@
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
"command": "dotnet test",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.12.2</Version>
|
||||
<Version>2026.1.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -13,21 +13,21 @@
|
||||
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
|
||||
|
||||
|
||||
<XUnitVersion>2.6.6</XUnitVersion>
|
||||
|
||||
|
||||
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
|
||||
|
||||
|
||||
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
|
||||
|
||||
|
||||
<NSubstituteVersion>5.1.0</NSubstituteVersion>
|
||||
|
||||
|
||||
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
|
||||
|
||||
|
||||
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.29102.190
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.14.36705.20 d17.14
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src - AGPL", "src - AGPL", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}"
|
||||
EndProject
|
||||
@@ -11,19 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DD5BD056-4
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.dockerignore = .dockerignore
|
||||
.editorconfig = .editorconfig
|
||||
.gitignore = .gitignore
|
||||
CONTRIBUTING.md = CONTRIBUTING.md
|
||||
Directory.Build.props = Directory.Build.props
|
||||
global.json = global.json
|
||||
.gitignore = .gitignore
|
||||
README.md = README.md
|
||||
.editorconfig = .editorconfig
|
||||
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
|
||||
SECURITY.md = SECURITY.md
|
||||
LICENSE_FAQ.md = LICENSE_FAQ.md
|
||||
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
|
||||
LICENSE_AGPL.txt = LICENSE_AGPL.txt
|
||||
LICENSE.txt = LICENSE.txt
|
||||
CONTRIBUTING.md = CONTRIBUTING.md
|
||||
.dockerignore = .dockerignore
|
||||
LICENSE_AGPL.txt = LICENSE_AGPL.txt
|
||||
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
|
||||
LICENSE_FAQ.md = LICENSE_FAQ.md
|
||||
README.md = README.md
|
||||
SECURITY.md = SECURITY.md
|
||||
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{3973D21B-A692-4B60-9B70-3631C057423A}"
|
||||
@@ -134,10 +134,16 @@ EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -350,10 +356,22 @@ Global
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -410,7 +428,10 @@ Global
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
|
||||
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||
|
||||
@@ -44,6 +44,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -462,6 +462,7 @@ public class AccountController : Controller
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
var provider = result.Properties.Items["scheme"];
|
||||
//Todo: Validate provider is a valid GUID with TryParse instead. When this is invalid it throws an exception
|
||||
var orgId = new Guid(provider);
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
|
||||
if (ssoConfig == null || !ssoConfig.Enabled)
|
||||
@@ -615,7 +616,7 @@ public class AccountController : Controller
|
||||
|
||||
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
|
||||
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
|
||||
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
|
||||
// We've verified that the user is Accepted or Confirmed, so we can create an SsoUser link and proceed
|
||||
// with authentication.
|
||||
await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);
|
||||
|
||||
@@ -680,22 +681,10 @@ public class AccountController : Controller
|
||||
ApiKey = CoreHelpers.SecureRandomString(30)
|
||||
};
|
||||
|
||||
/*
|
||||
The feature flag is checked here so that we can send the new MJML welcome email templates.
|
||||
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
|
||||
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
|
||||
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
|
||||
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
|
||||
TODO: Remove Feature flag: PM-28221
|
||||
*/
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
|
||||
{
|
||||
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _registerUserCommand.RegisterUser(newUser);
|
||||
}
|
||||
// Always use RegisterSSOAutoProvisionedUserAsync to ensure organization context is available
|
||||
// for domain validation (BlockClaimedDomainAccountCreation policy) and welcome emails.
|
||||
// The feature flag logic for welcome email templates is handled internally by RegisterUserCommand.
|
||||
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
|
||||
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Sso.IdentityServer;
|
||||
|
||||
/// <summary>
|
||||
/// Distributed cache-backed persisted grant store for short-lived grants.
|
||||
/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
|
||||
/// and fall back to in-memory caching if Redis is not configured.
|
||||
/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is purposefully a different implementation from how Identity solves Persisted Grants.
|
||||
/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
|
||||
/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
|
||||
/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
|
||||
/// mechanism (cache, database).
|
||||
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
|
||||
/// </remarks>
|
||||
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
|
||||
{
|
||||
private readonly IFusionCache _cache;
|
||||
|
||||
public DistributedCachePersistedGrantStore(
|
||||
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<PersistedGrant?> GetAsync(string key)
|
||||
{
|
||||
var result = await _cache.TryGetAsync<PersistedGrant>(key);
|
||||
|
||||
if (!result.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var grant = result.Value;
|
||||
|
||||
// Check if grant has expired - remove expired grants from cache
|
||||
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
|
||||
{
|
||||
await RemoveAsync(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return grant;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
// Cache stores are key-value based and don't support querying by filter criteria.
|
||||
// This method is typically used for cleanup operations on long-lived grants in databases.
|
||||
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
|
||||
|
||||
return Task.FromResult(Enumerable.Empty<PersistedGrant>());
|
||||
}
|
||||
|
||||
public Task RemoveAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
// Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
|
||||
// authentication cookies and performs federated logout with external IdPs. It does not invoke
|
||||
// Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
|
||||
// within 5 minutes, making explicit revocation unnecessary for SSO's security model.
|
||||
// https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
|
||||
|
||||
// Cache stores are key-value based and don't support bulk deletion by filter.
|
||||
// This method is typically used for cleanup operations on long-lived grants in databases.
|
||||
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string key)
|
||||
{
|
||||
await _cache.RemoveAsync(key);
|
||||
}
|
||||
|
||||
public async Task StoreAsync(PersistedGrant grant)
|
||||
{
|
||||
// Calculate TTL based on grant expiration
|
||||
var duration = grant.Expiration.HasValue
|
||||
? grant.Expiration.Value - DateTime.UtcNow
|
||||
: TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
|
||||
|
||||
// Ensure positive duration
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
|
||||
// give us a consistent cache key prefix for these grants.
|
||||
await _cache.SetAsync(
|
||||
grant.Key,
|
||||
grant,
|
||||
new FusionCacheEntryOptions { Duration = duration });
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class PersistedGrantsDistributedCacheConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
|
||||
/// well as the cache key/namespace for grant storage.
|
||||
/// </summary>
|
||||
public const string CacheKey = "sso-grants";
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer;
|
||||
using Bit.Sso.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.ResponseHandling;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
|
||||
@@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions
|
||||
})
|
||||
.AddIdentityServerCertificate(env, globalSettings);
|
||||
|
||||
// PM-23572
|
||||
// Register named FusionCache for SSO authorization code grants.
|
||||
// Provides separation of concerns and automatic Redis/in-memory negotiation
|
||||
// .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
|
||||
// and is separate from this keyed service, which only serves grant negotiation.
|
||||
services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
|
||||
|
||||
// Store authorization codes in distributed cache for horizontal scaling
|
||||
// Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
|
||||
services.AddSingleton<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
|
||||
|
||||
return identityServerBuilder;
|
||||
}
|
||||
}
|
||||
|
||||
94
bitwarden_license/src/Sso/package-lock.json
generated
94
bitwarden_license/src/Sso/package-lock.json
generated
@@ -17,9 +17,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -749,9 +749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||
"version": "2.9.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
|
||||
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -792,9 +792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -813,11 +813,11 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -834,9 +834,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001763",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
|
||||
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -988,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1022,9 +1022,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1418,13 +1418,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -1541,9 +1545,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1874,9 +1878,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.93.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
|
||||
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
|
||||
"version": "1.97.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
|
||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2109,9 +2113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"version": "5.3.16",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
|
||||
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2165,9 +2169,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2217,9 +2221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2232,21 +2236,21 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
"enhanced-resolve": "^5.17.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"json-parse-even-better-errors": "^2.3.1",
|
||||
"loader-runner": "^4.2.0",
|
||||
"loader-runner": "^4.3.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
},
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -21,7 +20,6 @@ using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
@@ -1013,133 +1011,6 @@ public class AccountControllerTest
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
|
||||
SutProvider<AccountController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var orgId = Guid.NewGuid();
|
||||
var providerUserId = "ext-new-user";
|
||||
var email = "newuser@example.com";
|
||||
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
|
||||
|
||||
// No existing user (JIT provisioning scenario)
|
||||
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
|
||||
.Returns((OrganizationUser?)null);
|
||||
|
||||
// Feature flag enabled
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
|
||||
.Returns(true);
|
||||
|
||||
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
|
||||
sutProvider.GetDependency<IRegisterUserCommand>()
|
||||
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtClaimTypes.Email, email),
|
||||
new Claim(JwtClaimTypes.Name, "New User")
|
||||
} as IEnumerable<Claim>;
|
||||
var config = new SsoConfigurationData();
|
||||
|
||||
var method = typeof(AccountController).GetMethod(
|
||||
"CreateUserAndOrgUserConditionallyAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(method);
|
||||
|
||||
// Act
|
||||
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
|
||||
sutProvider.Sut,
|
||||
new object[]
|
||||
{
|
||||
orgId.ToString(),
|
||||
providerUserId,
|
||||
claims,
|
||||
null!,
|
||||
config
|
||||
})!;
|
||||
|
||||
var result = await task;
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
|
||||
.RegisterSSOAutoProvisionedUserAsync(
|
||||
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
|
||||
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
|
||||
|
||||
Assert.NotNull(result.user);
|
||||
Assert.Equal(email, result.user.Email);
|
||||
Assert.Equal(organization.Id, result.organization.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
|
||||
SutProvider<AccountController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var orgId = Guid.NewGuid();
|
||||
var providerUserId = "ext-legacy-user";
|
||||
var email = "legacyuser@example.com";
|
||||
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
|
||||
|
||||
// No existing user (JIT provisioning scenario)
|
||||
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
|
||||
.Returns((OrganizationUser?)null);
|
||||
|
||||
// Feature flag disabled
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
|
||||
.Returns(false);
|
||||
|
||||
// Mock the RegisterUser to return success
|
||||
sutProvider.GetDependency<IRegisterUserCommand>()
|
||||
.RegisterUser(Arg.Any<User>())
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtClaimTypes.Email, email),
|
||||
new Claim(JwtClaimTypes.Name, "Legacy User")
|
||||
} as IEnumerable<Claim>;
|
||||
var config = new SsoConfigurationData();
|
||||
|
||||
var method = typeof(AccountController).GetMethod(
|
||||
"CreateUserAndOrgUserConditionallyAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(method);
|
||||
|
||||
// Act
|
||||
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
|
||||
sutProvider.Sut,
|
||||
new object[]
|
||||
{
|
||||
orgId.ToString(),
|
||||
providerUserId,
|
||||
claims,
|
||||
null!,
|
||||
config
|
||||
})!;
|
||||
|
||||
var result = await task;
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
|
||||
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
|
||||
|
||||
// Verify the new method was NOT called
|
||||
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
|
||||
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
|
||||
|
||||
Assert.NotNull(result.user);
|
||||
Assert.Equal(email, result.user.Email);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ExternalChallenge_WithMatchingOrgId_Succeeds(
|
||||
SutProvider<AccountController> sutProvider,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using Bit.Sso.IdentityServer;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using NSubstitute;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.SSO.Test.IdentityServer;
|
||||
|
||||
public class DistributedCachePersistedGrantStoreTests
|
||||
{
|
||||
private readonly IFusionCache _cache;
|
||||
private readonly DistributedCachePersistedGrantStore _sut;
|
||||
|
||||
public DistributedCachePersistedGrantStoreTests()
|
||||
{
|
||||
_cache = Substitute.For<IFusionCache>();
|
||||
_sut = new DistributedCachePersistedGrantStore(_cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_StoresGrantWithCalculatedTTL()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"test-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts =>
|
||||
opts.Duration >= TimeSpan.FromMinutes(4.9) &&
|
||||
opts.Duration <= TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("no-expiry-key", expiration: null);
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"no-expiry-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts => opts.Duration == TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
|
||||
{
|
||||
// Arrange
|
||||
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(expiredGrant);
|
||||
|
||||
// Assert
|
||||
await _cache.DidNotReceive().SetAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<PersistedGrant>(),
|
||||
Arg.Any<FusionCacheEntryOptions?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_EnablesDistributedCache()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"distributed-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts =>
|
||||
opts.SkipDistributedCache == false &&
|
||||
opts.SkipDistributedCacheReadWhenStale == false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithValidGrant_ReturnsGrant()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
_cache.TryGetAsync<PersistedGrant>("valid-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("valid-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("valid-key", result.Key);
|
||||
Assert.Equal("authorization_code", result.Type);
|
||||
Assert.Equal("test-subject", result.SubjectId);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.None);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("nonexistent-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
|
||||
_cache.TryGetAsync<PersistedGrant>("expired-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(expiredGrant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("expired-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await _cache.Received(1).RemoveAsync("expired-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNoExpiration_ReturnsGrant()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("no-expiry-key", expiration: null);
|
||||
_cache.TryGetAsync<PersistedGrant>("no-expiry-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("no-expiry-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("no-expiry-key", result.Key);
|
||||
Assert.Null(result.Expiration);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_RemovesGrantFromCache()
|
||||
{
|
||||
// Act
|
||||
await _sut.RemoveAsync("remove-key");
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).RemoveAsync("remove-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new PersistedGrantFilter
|
||||
{
|
||||
SubjectId = "test-subject",
|
||||
SessionId = "test-session",
|
||||
ClientId = "test-client",
|
||||
Type = "authorization_code"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAllAsync(filter);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAllAsync_CompletesWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new PersistedGrantFilter
|
||||
{
|
||||
SubjectId = "test-subject",
|
||||
ClientId = "test-client"
|
||||
};
|
||||
|
||||
// Act & Assert - should not throw
|
||||
await _sut.RemoveAllAsync(filter);
|
||||
|
||||
// Verify no cache operations were performed
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_PreservesAllGrantProperties()
|
||||
{
|
||||
// Arrange
|
||||
var grant = new PersistedGrant
|
||||
{
|
||||
Key = "full-grant-key",
|
||||
Type = "authorization_code",
|
||||
SubjectId = "user-123",
|
||||
SessionId = "session-456",
|
||||
ClientId = "client-789",
|
||||
Description = "Test grant",
|
||||
CreationTime = DateTime.UtcNow.AddMinutes(-1),
|
||||
Expiration = DateTime.UtcNow.AddMinutes(5),
|
||||
ConsumedTime = null,
|
||||
Data = "{\"test\":\"data\"}"
|
||||
};
|
||||
|
||||
PersistedGrant? capturedGrant = null;
|
||||
await _cache.SetAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Do<PersistedGrant>(g => capturedGrant = g),
|
||||
Arg.Any<FusionCacheEntryOptions?>());
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedGrant);
|
||||
Assert.Equal(grant.Key, capturedGrant.Key);
|
||||
Assert.Equal(grant.Type, capturedGrant.Type);
|
||||
Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
|
||||
Assert.Equal(grant.SessionId, capturedGrant.SessionId);
|
||||
Assert.Equal(grant.ClientId, capturedGrant.ClientId);
|
||||
Assert.Equal(grant.Description, capturedGrant.Description);
|
||||
Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
|
||||
Assert.Equal(grant.Expiration, capturedGrant.Expiration);
|
||||
Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
|
||||
Assert.Equal(grant.Data, capturedGrant.Data);
|
||||
}
|
||||
|
||||
private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
|
||||
{
|
||||
return new PersistedGrant
|
||||
{
|
||||
Key = key,
|
||||
Type = "authorization_code",
|
||||
SubjectId = "test-subject",
|
||||
ClientId = "test-client",
|
||||
CreationTime = DateTime.UtcNow,
|
||||
Expiration = expiration,
|
||||
Data = "{\"test\":\"data\"}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Properties\launchSettings.json">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Properties\launchSettings.json">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
using System.Net;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Sso.IntegrationTest.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using AuthenticationSchemes = Bit.Core.AuthenticationSchemes;
|
||||
|
||||
namespace Bit.Sso.IntegrationTest.Controllers;
|
||||
|
||||
public class AccountControllerTests(SsoApplicationFactory factory) : IClassFixture<SsoApplicationFactory>
|
||||
{
|
||||
private readonly SsoApplicationFactory _factory = factory;
|
||||
|
||||
/*
|
||||
* Test to verify the /Account/ExternalCallback endpoint exists and is reachable.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_EndpointExists_ReturnsExpectedStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Act - Verify the endpoint is accessible (even if it fails due to missing auth)
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - The endpoint should exist and return 500 (not 404) due to missing authentication
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify calling /Account/ExternalCallback without an authentication cookie
|
||||
* results in an error as expected.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Act - Call ExternalCallback without proper authentication setup
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because there's no external authentication cookie
|
||||
Assert.False(response.IsSuccessStatusCode);
|
||||
// The endpoint will throw an exception when authentication fails
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag
|
||||
*/
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError
|
||||
(
|
||||
bool featureFlagEnabled
|
||||
)
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled);
|
||||
services.AddSingleton(featureService);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert
|
||||
Assert.False(response.IsSuccessStatusCode);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify behavior of /Account/ExternalCallback simulating failed authentication.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithMockedAuthenticationService_FailedAuth_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithFailedAuthentication()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert
|
||||
Assert.False(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when SSO config exists but is disabled.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithDisabledSsoConfig_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig(ssoConfig => ssoConfig!.Enabled = false)
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because SSO config is disabled
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExternalCallback_FindUserFromExternalProviderAsync_OrganizationOrSsoConfigNotFound_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user has invalid status
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when SSO config expects an ACR value
|
||||
* but the authentication response has a missing or invalid ACR claim.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExpectedAcrValue_AndInvalidAcr_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig(ssoConfig => ssoConfig!.SetData(
|
||||
new SsoConfigurationData
|
||||
{
|
||||
ExpectedReturnAcrValue = "urn:expected:acr:value"
|
||||
}))
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because ACR claim is missing or invalid
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Expected authentication context class reference (acr) was not returned with the authentication response or is invalid", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when the authentication response
|
||||
* does not contain any recognizable user ID claim (sub, NameIdentifier, uid, upn, eppn).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithNoUserIdClaim_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.OmitProviderUserId()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback"); ;
|
||||
|
||||
// Assert - Should fail because no user ID claim was found
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Unknown userid", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when no email claim is found
|
||||
* and the providerUserId cannot be used as a fallback email (doesn't contain @).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithNoEmailClaim_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithNullEmail()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because no email claim was found
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Cannot find email claim", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when an existing user
|
||||
* uses Key Connector but has no org user record (was removed from organization).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExistingKeyConnectorUser_AndNoOrgUser_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser(user =>
|
||||
{
|
||||
user.UsesKeyConnector = true;
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user uses Key Connector but has no org user record
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when an existing user
|
||||
* uses Key Connector and has an org user record in the invited status.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExistingKeyConnectorUser_AndInvitedOrgUser_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig(ssoConfig => { })
|
||||
.WithUser(user =>
|
||||
{
|
||||
user.UsesKeyConnector = true;
|
||||
})
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = OrganizationUserStatusType.Invited;
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user uses Key Connector but the Org user is in the invited status
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when an existing user
|
||||
* (not using Key Connector) has no org user record - they were removed from the organization.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExistingUser_AndNoOrgUser_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user exists but has no org user record
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("You were removed from the organization managing single sign-on for your account. Contact the organization administrator", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when an existing user
|
||||
* has an org user record with Invited status - they must accept the invite first.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExistingUser_AndInvitedOrgUserStatus_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = OrganizationUserStatusType.Invited;
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user must accept invite before using SSO
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("you must first log in using your master password", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when organization has no available seats
|
||||
* and cannot auto-scale because it's a self-hosted instance.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_ReturnsError()
|
||||
{
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithOrganization(org =>
|
||||
{
|
||||
org.Seats = 5; // Organization has seat limit
|
||||
})
|
||||
.AsSelfHosted()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because no seats available and cannot auto-scale on self-hosted
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("No seats available for organization", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when organization has no available seats
|
||||
* and auto-scaling fails (e.g., billing issue, max seats reached).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_ReturnsError()
|
||||
{
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithOrganization(org =>
|
||||
{
|
||||
org.Seats = 5;
|
||||
org.MaxAutoscaleSeats = 5;
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because auto-adding seats failed
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("No seats available for organization", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when email cannot be found
|
||||
* during new user provisioning (Scenario 2) after bypassing the first email check
|
||||
* via manual linking path (userIdentifier is set).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithUserIdentifier_AndNoEmail_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUserIdentifier("")
|
||||
.WithNullEmail()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because email cannot be found during new user provisioning
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Cannot find email claim", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when org user has an unknown/invalid status.
|
||||
* This tests defensive code that handles future enum values or data corruption scenarios.
|
||||
* We simulate this by casting an invalid integer to OrganizationUserStatusType.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithUnknownOrgUserStatus_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = (OrganizationUserStatusType)99; // Invalid enum value - simulates future status or data corruption
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because org user status is unknown/invalid
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("is in an unknown state", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
// Note: "User should be found ln 304" appears to be unreachable defensive code.
|
||||
// CreateUserAndOrgUserConditionallyAsync always returns a non-null user or throws an exception,
|
||||
// so possibleSsoLinkedUser cannot be null when the feature flag check executes.
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when userIdentifier
|
||||
* is malformed (doesn't contain comma separator for userId,token format).
|
||||
* There is only a single test case here but in the future we may need to expand the
|
||||
* tests to cover other invalid formats.
|
||||
*/
|
||||
[Theory]
|
||||
[BitAutoData("No-Comas-Identifier")]
|
||||
public async Task ExternalCallback_WithInvalidUserIdentifierFormat_ReturnsError(
|
||||
string UserIdentifier
|
||||
)
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUserIdentifier(UserIdentifier)
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because userIdentifier format is invalid
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Invalid user identifier", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when userIdentifier
|
||||
* contains valid userId but invalid/mismatched token.
|
||||
*
|
||||
* NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because:
|
||||
* - The userIdentifier in the auth result must contain a userId that matches a user in the system
|
||||
* - User.SetNewId() always overwrites the Id (unlike Organization.SetNewId() which has a guard)
|
||||
* - This means we cannot pre-set a User.Id before database insertion
|
||||
* - The auth mock must be configured BEFORE accessing factory.Services (required by SubstituteService)
|
||||
* - Therefore, we cannot coordinate the userId between the auth mock and the seeded user
|
||||
* - Using substitutes allows us to control the exact userId and mock UserManager.VerifyUserTokenAsync
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = Guid.NewGuid();
|
||||
var providerUserId = Guid.NewGuid().ToString();
|
||||
var userId = Guid.NewGuid();
|
||||
var testEmail = "test_user@integration.test";
|
||||
var testName = "Test User";
|
||||
// Valid format but token won't verify
|
||||
var userIdentifier = $"{userId},invalid-token";
|
||||
|
||||
var claimedUser = new User
|
||||
{
|
||||
Id = userId,
|
||||
Email = testEmail,
|
||||
Name = testName
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Name = "Test Organization",
|
||||
Enabled = true,
|
||||
UseSso = true
|
||||
};
|
||||
|
||||
var ssoConfig = new SsoConfig
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Enabled = true
|
||||
};
|
||||
ssoConfig.SetData(new SsoConfigurationData());
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
|
||||
services.AddSingleton(featureService);
|
||||
|
||||
// Mock organization repository
|
||||
var orgRepo = Substitute.For<IOrganizationRepository>();
|
||||
orgRepo.GetByIdAsync(organizationId).Returns(organization);
|
||||
orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization);
|
||||
services.AddSingleton(orgRepo);
|
||||
|
||||
// Mock SSO config repository
|
||||
var ssoConfigRepo = Substitute.For<ISsoConfigRepository>();
|
||||
ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig);
|
||||
services.AddSingleton(ssoConfigRepo);
|
||||
|
||||
// Mock user repository - no existing user via SSO
|
||||
var userRepo = Substitute.For<IUserRepository>();
|
||||
userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null);
|
||||
services.AddSingleton(userRepo);
|
||||
|
||||
// Mock user service - returns user for manual linking lookup
|
||||
var userService = Substitute.For<IUserService>();
|
||||
userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser);
|
||||
services.AddSingleton(userService);
|
||||
|
||||
// Mock UserManager to return false for token verification
|
||||
var userManager = Substitute.For<UserManager<User>>(
|
||||
Substitute.For<IUserStore<User>>(), null, null, null, null, null, null, null, null);
|
||||
userManager.VerifyUserTokenAsync(
|
||||
claimedUser,
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>())
|
||||
.Returns(false);
|
||||
services.AddSingleton(userManager);
|
||||
|
||||
// Mock authentication service with userIdentifier that has valid format but invalid token
|
||||
var authService = Substitute.For<IAuthenticationService>();
|
||||
authService.AuthenticateAsync(
|
||||
Arg.Any<HttpContext>(),
|
||||
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
|
||||
.Returns(MockSuccessfulAuthResult.Build(organizationId, providerUserId, testEmail, testName, null, userIdentifier));
|
||||
services.AddSingleton(authService);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because token verification failed
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Supplied userId and token did not match", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = OrganizationUserStatusType.Revoked;
|
||||
})
|
||||
.WithFeatureFlags(factoryService =>
|
||||
{
|
||||
factoryService.SubstituteService<IFeatureService>(srv =>
|
||||
{
|
||||
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
|
||||
});
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user state is invalid
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(
|
||||
$"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.",
|
||||
stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = OrganizationUserStatusType.Revoked;
|
||||
})
|
||||
.WithFeatureFlags(factoryService =>
|
||||
{
|
||||
factoryService.SubstituteService<IFeatureService>(srv =>
|
||||
{
|
||||
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false);
|
||||
});
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user has invalid status
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(
|
||||
$"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.",
|
||||
stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = OrganizationUserStatusType.Invited;
|
||||
})
|
||||
.WithFeatureFlags(factoryService =>
|
||||
{
|
||||
factoryService.SubstituteService<IFeatureService>(srv =>
|
||||
{
|
||||
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false);
|
||||
});
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because user has invalid status
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(
|
||||
$"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.",
|
||||
stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when user is found via SSO
|
||||
* but has no organization user record (with feature flag enabled).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithSsoUser()
|
||||
.WithFeatureFlags(factoryService =>
|
||||
{
|
||||
factoryService.SubstituteService<IFeatureService>(srv =>
|
||||
{
|
||||
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
|
||||
});
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because org user cannot be found
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Could not find organization user", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when the provider scheme
|
||||
* is not a valid GUID (SSOProviderIsNotAnOrgId).
|
||||
*
|
||||
* NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because:
|
||||
* - Organization.Id is of type Guid and cannot be set to a non-GUID value
|
||||
* - The auth mock scheme must be a non-GUID string to trigger this error path
|
||||
* - This cannot be tested since ln 438 in AccountController.FindUserFromExternalProviderAsync throws a different exception
|
||||
* before reaching the organization lookup exception.
|
||||
*/
|
||||
[Fact(Skip = "This test cannot be executed because the organization ID must be a GUID. See note in test summary.")]
|
||||
public async Task ExternalCallback_WithInvalidProviderGuid_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var invalidScheme = "not-a-valid-guid";
|
||||
var providerUserId = Guid.NewGuid().ToString();
|
||||
var testEmail = "test@example.com";
|
||||
var testName = "Test User";
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Mock authentication service with invalid (non-GUID) scheme
|
||||
var authService = Substitute.For<IAuthenticationService>();
|
||||
authService.AuthenticateAsync(
|
||||
Arg.Any<HttpContext>(),
|
||||
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
|
||||
.Returns(MockSuccessfulAuthResult.Build(invalidScheme, providerUserId, testEmail, testName));
|
||||
services.AddSingleton(authService);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because provider is not a valid organization GUID
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Organization not found from identifier.", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* Test to verify /Account/ExternalCallback returns error when the organization ID
|
||||
* in the auth result does not match any organization in the database.
|
||||
* NOTE: This code path is unreachable because the SsoConfig must exist to proceed, but there is a circular dependency:
|
||||
* - SsoConfig cannot exist without a valid Organization but the test is testing that an Organization cannot be found.
|
||||
*/
|
||||
[Fact(Skip = "This code path is unreachable because the SsoConfig must exist to proceed. But the SsoConfig cannot exist without a valid Organization.")]
|
||||
public async Task ExternalCallback_WithNonExistentOrganization_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithNonExistentOrganizationInAuth()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should fail because organization cannot be found by the ID in auth result
|
||||
var stringResponse = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Could not find organization", stringResponse);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
/*
|
||||
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing
|
||||
* SSO-linked user logs in (user exists in SsoUser table).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExistingSsoUser_ReturnsSuccess()
|
||||
{
|
||||
// Arrange - User with SSO link already exists
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser()
|
||||
.WithSsoUser()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should succeed and redirect
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Redirect,
|
||||
$"Expected success/redirect but got {response.StatusCode}");
|
||||
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
}
|
||||
|
||||
/*
|
||||
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when JIT provisioning
|
||||
* a new user (user doesn't exist, gets created automatically).
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithJitProvisioning_ReturnsSuccess()
|
||||
{
|
||||
// Arrange - No user, no org user - JIT provisioning will create both
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should succeed and redirect
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Redirect,
|
||||
$"Expected success/redirect but got {response.StatusCode}");
|
||||
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
}
|
||||
|
||||
/*
|
||||
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user
|
||||
* with a valid (Confirmed) organization user status logs in via SSO for the first time.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExistingUserAndConfirmedOrgUser_ReturnsSuccess()
|
||||
{
|
||||
// Arrange - Existing user with confirmed org user status, no SSO link yet
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should succeed and redirect
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Redirect,
|
||||
$"Expected success/redirect but got {response.StatusCode}");
|
||||
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
}
|
||||
|
||||
/*
|
||||
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user
|
||||
* with Accepted organization user status logs in via SSO.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithExistingUserAndAcceptedOrgUser_ReturnsSuccess()
|
||||
{
|
||||
// Arrange - Existing user with accepted org user status
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser(orgUser =>
|
||||
{
|
||||
orgUser.Status = OrganizationUserStatusType.Accepted;
|
||||
})
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Should succeed and redirect
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Redirect,
|
||||
$"Expected success/redirect but got {response.StatusCode}");
|
||||
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
}
|
||||
|
||||
/*
|
||||
* SUCCESS PATH: Test to verify /Account/ExternalCallback returns a View with 200 status
|
||||
* when the client is a native application (uses custom URI scheme like "bitwarden://callback").
|
||||
* Native clients get a different response for better UX - a 200 with redirect view instead of 302.
|
||||
* See AccountController lines 371-378.
|
||||
*/
|
||||
[Fact]
|
||||
public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status()
|
||||
{
|
||||
// Arrange - Existing SSO user with native client context
|
||||
var testData = await new SsoTestDataBuilder()
|
||||
.WithSsoConfig()
|
||||
.WithUser()
|
||||
.WithOrganizationUser()
|
||||
.WithSsoUser()
|
||||
.AsNativeClient()
|
||||
.BuildAsync();
|
||||
|
||||
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/Account/ExternalCallback");
|
||||
|
||||
// Assert - Native clients get 200 status with a redirect view instead of 302
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// The Location header should be empty for native clients (set in controller)
|
||||
// and the response should contain the redirect view
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.NotEmpty(content); // View content should be present
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Sso.IntegrationTest": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:59973;http://localhost:59974"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Properties\launchSettings.json">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Bit.IntegrationTestCommon.Factories;
|
||||
|
||||
namespace Bit.Sso.IntegrationTest.Utilities;
|
||||
|
||||
public class SsoApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using NSubstitute;
|
||||
using AuthenticationSchemes = Bit.Core.AuthenticationSchemes;
|
||||
|
||||
namespace Bit.Sso.IntegrationTest.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Contains the factory and all entities created by <see cref="SsoTestDataBuilder"/> for use in integration tests.
|
||||
/// </summary>
|
||||
public record SsoTestData(
|
||||
SsoApplicationFactory Factory,
|
||||
Organization? Organization,
|
||||
User? User,
|
||||
OrganizationUser? OrganizationUser,
|
||||
SsoConfig? SsoConfig,
|
||||
SsoUser? SsoUser);
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating SSO test data with seeded database entities.
|
||||
/// </summary>
|
||||
public class SsoTestDataBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider.
|
||||
/// </summary>
|
||||
private string? _userIdentifier;
|
||||
private Action<Organization>? _organizationConfig;
|
||||
private Action<User>? _userConfig;
|
||||
private Action<OrganizationUser>? _orgUserConfig;
|
||||
private Action<SsoConfig>? _ssoConfigConfig;
|
||||
private Action<SsoUser>? _ssoUserConfig;
|
||||
private Action<SsoApplicationFactory>? _featureFlagConfig;
|
||||
|
||||
private bool _includeUser = false;
|
||||
private bool _includeSsoUser = false;
|
||||
private bool _includeOrganizationUser = false;
|
||||
private bool _includeSsoConfig = false;
|
||||
private bool _successfulAuth = true;
|
||||
private bool _withNullEmail = false;
|
||||
private bool _isSelfHosted = false;
|
||||
private bool _includeProviderUserId = true;
|
||||
private bool _useNonExistentOrgInAuth = false;
|
||||
private bool _isNativeClient = false;
|
||||
|
||||
public SsoTestDataBuilder WithOrganization(Action<Organization> configure)
|
||||
{
|
||||
_organizationConfig = configure;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithUser(Action<User>? configure = null)
|
||||
{
|
||||
_includeUser = true;
|
||||
_userConfig = configure;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithOrganizationUser(Action<OrganizationUser>? configure = null)
|
||||
{
|
||||
_includeOrganizationUser = true;
|
||||
_orgUserConfig = configure;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithSsoConfig(Action<SsoConfig>? configure = null)
|
||||
{
|
||||
_includeSsoConfig = true;
|
||||
_ssoConfigConfig = configure;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithSsoUser(Action<SsoUser>? configure = null)
|
||||
{
|
||||
_includeSsoUser = true;
|
||||
_ssoUserConfig = configure;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithFeatureFlags(Action<SsoApplicationFactory> configure)
|
||||
{
|
||||
_featureFlagConfig = configure;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithFailedAuthentication()
|
||||
{
|
||||
_successfulAuth = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithNullEmail()
|
||||
{
|
||||
_withNullEmail = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder WithUserIdentifier(string userIdentifier)
|
||||
{
|
||||
_userIdentifier = userIdentifier;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder OmitProviderUserId()
|
||||
{
|
||||
_includeProviderUserId = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsoTestDataBuilder AsSelfHosted()
|
||||
{
|
||||
_isSelfHosted = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Causes the auth result to use a different (non-existent) organization ID than what is seeded
|
||||
/// in the database. This simulates the "organization not found" scenario.
|
||||
/// </summary>
|
||||
public SsoTestDataBuilder WithNonExistentOrganizationInAuth()
|
||||
{
|
||||
_useNonExistentOrgInAuth = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the test to simulate a native client (non-browser) OIDC flow.
|
||||
/// Native clients use custom URI schemes (e.g., "bitwarden://callback") instead of http/https.
|
||||
/// This causes ExternalCallback to return a View with 200 status instead of a redirect.
|
||||
/// </summary>
|
||||
public SsoTestDataBuilder AsNativeClient()
|
||||
{
|
||||
_isNativeClient = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async Task<SsoTestData> BuildAsync()
|
||||
{
|
||||
// Create factory
|
||||
var factory = new SsoApplicationFactory();
|
||||
|
||||
// Pre-generate IDs and values needed for auth mock (before accessing Services)
|
||||
var organizationId = Guid.NewGuid();
|
||||
// Use a different org ID in auth if testing "organization not found" scenario
|
||||
var authOrganizationId = _useNonExistentOrgInAuth ? Guid.NewGuid() : organizationId;
|
||||
var providerUserId = _includeProviderUserId ? Guid.NewGuid().ToString() : "";
|
||||
var userEmail = _withNullEmail ? null : $"user_{Guid.NewGuid()}@test.com";
|
||||
var userName = "TestUser";
|
||||
|
||||
// 1. Configure mocked authentication service BEFORE accessing Services
|
||||
factory.SubstituteService<IAuthenticationService>(authService =>
|
||||
{
|
||||
if (_successfulAuth)
|
||||
{
|
||||
authService.AuthenticateAsync(
|
||||
Arg.Any<HttpContext>(),
|
||||
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
|
||||
.Returns(MockSuccessfulAuthResult.Build(
|
||||
authOrganizationId,
|
||||
providerUserId,
|
||||
userEmail,
|
||||
userName,
|
||||
acrValue: null,
|
||||
_userIdentifier));
|
||||
}
|
||||
else
|
||||
{
|
||||
authService.AuthenticateAsync(
|
||||
Arg.Any<HttpContext>(),
|
||||
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
|
||||
.Returns(AuthenticateResult.Fail("External authentication error"));
|
||||
}
|
||||
});
|
||||
|
||||
// 1.a Configure GlobalSettings for Self-Hosted and seat limit
|
||||
factory.SubstituteService<IGlobalSettings>(globalSettings =>
|
||||
{
|
||||
globalSettings.SelfHosted.Returns(_isSelfHosted);
|
||||
});
|
||||
|
||||
// 1.b configure setting feature flags
|
||||
_featureFlagConfig?.Invoke(factory);
|
||||
|
||||
// 1.c Configure IIdentityServerInteractionService for native client flow
|
||||
if (_isNativeClient)
|
||||
{
|
||||
factory.SubstituteService<IIdentityServerInteractionService>(interaction =>
|
||||
{
|
||||
// Native clients have redirect URIs that don't start with http/https
|
||||
// e.g., "bitwarden://callback" or "com.bitwarden.app://callback"
|
||||
var authorizationRequest = new AuthorizationRequest
|
||||
{
|
||||
RedirectUri = "bitwarden://sso-callback"
|
||||
};
|
||||
interaction.GetAuthorizationContextAsync(Arg.Any<string>())
|
||||
.Returns(authorizationRequest);
|
||||
});
|
||||
}
|
||||
|
||||
if (!_successfulAuth)
|
||||
{
|
||||
return new SsoTestData(factory, null!, null!, null!, null!, null!);
|
||||
}
|
||||
|
||||
// 2. Create Organization with defaults (using pre-generated ID)
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Name = "Test Organization",
|
||||
BillingEmail = "billing@test.com",
|
||||
Plan = "Enterprise",
|
||||
Enabled = true,
|
||||
UseSso = true
|
||||
};
|
||||
_organizationConfig?.Invoke(organization);
|
||||
|
||||
var orgRepo = factory.Services.GetRequiredService<IOrganizationRepository>();
|
||||
organization = await orgRepo.CreateAsync(organization);
|
||||
|
||||
// 3. Create User with defaults (using pre-generated values)
|
||||
User? user = null;
|
||||
if (_includeUser)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Email = userEmail ?? $"email_{Guid.NewGuid()}@test.dev",
|
||||
Name = userName,
|
||||
ApiKey = Guid.NewGuid().ToString(),
|
||||
SecurityStamp = Guid.NewGuid().ToString()
|
||||
};
|
||||
_userConfig?.Invoke(user);
|
||||
|
||||
var userRepo = factory.Services.GetRequiredService<IUserRepository>();
|
||||
user = await userRepo.CreateAsync(user);
|
||||
}
|
||||
|
||||
// 4. Create OrganizationUser linking them
|
||||
OrganizationUser? orgUser = null;
|
||||
if (_includeOrganizationUser)
|
||||
{
|
||||
orgUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user!.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User
|
||||
};
|
||||
_orgUserConfig?.Invoke(orgUser);
|
||||
|
||||
var orgUserRepo = factory.Services.GetRequiredService<IOrganizationUserRepository>();
|
||||
orgUser = await orgUserRepo.CreateAsync(orgUser);
|
||||
}
|
||||
|
||||
// 4.a Create many OrganizationUser to test seat count logic
|
||||
if (organization.Seats > 1)
|
||||
{
|
||||
var orgUserRepo = factory.Services.GetRequiredService<IOrganizationUserRepository>();
|
||||
var userRepo = factory.Services.GetRequiredService<IUserRepository>();
|
||||
var additionalOrgUsers = new List<OrganizationUser>();
|
||||
for (var i = 1; i <= organization.Seats; i++)
|
||||
{
|
||||
var additionalUser = new User
|
||||
{
|
||||
Email = $"additional_user_{i}_{Guid.NewGuid()}@test.dev",
|
||||
Name = $"AdditionalUser{i}",
|
||||
ApiKey = Guid.NewGuid().ToString(),
|
||||
SecurityStamp = Guid.NewGuid().ToString()
|
||||
};
|
||||
var createdAdditionalUser = await userRepo.CreateAsync(additionalUser);
|
||||
|
||||
var additionalOrgUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = createdAdditionalUser.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User
|
||||
};
|
||||
additionalOrgUsers.Add(additionalOrgUser);
|
||||
}
|
||||
await orgUserRepo.CreateManyAsync(additionalOrgUsers);
|
||||
}
|
||||
|
||||
// 5. Create SsoConfig, if ssoConfigConfig is not null
|
||||
SsoConfig? ssoConfig = null;
|
||||
if (_includeSsoConfig)
|
||||
{
|
||||
ssoConfig = new SsoConfig
|
||||
{
|
||||
OrganizationId = authOrganizationId,
|
||||
Enabled = true
|
||||
};
|
||||
ssoConfig.SetData(new SsoConfigurationData());
|
||||
_ssoConfigConfig?.Invoke(ssoConfig);
|
||||
|
||||
var ssoConfigRepo = factory.Services.GetRequiredService<ISsoConfigRepository>();
|
||||
ssoConfig = await ssoConfigRepo.CreateAsync(ssoConfig);
|
||||
}
|
||||
|
||||
// 6. Optionally create SsoUser (using pre-generated providerUserId as ExternalId)
|
||||
SsoUser? ssoUser = null;
|
||||
if (_includeSsoUser)
|
||||
{
|
||||
ssoUser = new SsoUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user!.Id,
|
||||
ExternalId = providerUserId
|
||||
};
|
||||
_ssoUserConfig?.Invoke(ssoUser);
|
||||
|
||||
var ssoUserRepo = factory.Services.GetRequiredService<ISsoUserRepository>();
|
||||
ssoUser = await ssoUserRepo.CreateAsync(ssoUser);
|
||||
}
|
||||
|
||||
return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core;
|
||||
using Duende.IdentityModel;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock for use in tests requiring a valid external authentication result.
|
||||
/// </summary>
|
||||
internal static class MockSuccessfulAuthResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Since this tests the external Authentication flow, only the OrganizationId is strictly required.
|
||||
/// However, some tests may require additional claims to be present, so they can be optionally added.
|
||||
/// </summary>
|
||||
/// <param name="organizationId"></param>
|
||||
/// <param name="providerUserId"></param>
|
||||
/// <param name="email"></param>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="acrValue"></param>
|
||||
/// <param name="userIdentifier"></param>
|
||||
/// <returns></returns>
|
||||
public static AuthenticateResult Build(
|
||||
Guid organizationId,
|
||||
string? providerUserId,
|
||||
string? email,
|
||||
string? name = null,
|
||||
string? acrValue = null,
|
||||
string? userIdentifier = null)
|
||||
{
|
||||
return Build(organizationId.ToString(), providerUserId, email, name, acrValue, userIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios
|
||||
/// where the scheme is not a valid GUID.
|
||||
/// </summary>
|
||||
public static AuthenticateResult Build(
|
||||
string scheme,
|
||||
string? providerUserId,
|
||||
string? email,
|
||||
string? name = null,
|
||||
string? acrValue = null,
|
||||
string? userIdentifier = null)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
|
||||
if (!string.IsNullOrEmpty(email))
|
||||
{
|
||||
claims.Add(new Claim(JwtClaimTypes.Email, email));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(providerUserId))
|
||||
{
|
||||
claims.Add(new Claim(JwtClaimTypes.Subject, providerUserId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
claims.Add(new Claim(JwtClaimTypes.Name, name));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(acrValue))
|
||||
{
|
||||
claims.Add(new Claim(JwtClaimTypes.AuthenticationContextClassReference, acrValue));
|
||||
}
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External"));
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
Items =
|
||||
{
|
||||
["scheme"] = scheme,
|
||||
["return_url"] = "~/",
|
||||
["state"] = "test-state",
|
||||
["user_identifier"] = userIdentifier ?? string.Empty
|
||||
}
|
||||
};
|
||||
|
||||
var ticket = new AuthenticationTicket(
|
||||
principal,
|
||||
properties,
|
||||
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
23
dev/setup_secrets.ps1
Normal file → Executable file
23
dev/setup_secrets.ps1
Normal file → Executable file
@@ -2,7 +2,7 @@
|
||||
# Helper script for applying the same user secrets to each project
|
||||
param (
|
||||
[switch]$clear,
|
||||
[Parameter(ValueFromRemainingArguments = $true, Position=1)]
|
||||
[Parameter(ValueFromRemainingArguments = $true, Position = 1)]
|
||||
$cmdArgs
|
||||
)
|
||||
|
||||
@@ -16,17 +16,18 @@ if ($clear -eq $true) {
|
||||
}
|
||||
|
||||
$projects = @{
|
||||
Admin = "../src/Admin"
|
||||
Api = "../src/Api"
|
||||
Billing = "../src/Billing"
|
||||
Events = "../src/Events"
|
||||
EventsProcessor = "../src/EventsProcessor"
|
||||
Icons = "../src/Icons"
|
||||
Identity = "../src/Identity"
|
||||
Notifications = "../src/Notifications"
|
||||
Sso = "../bitwarden_license/src/Sso"
|
||||
Scim = "../bitwarden_license/src/Scim"
|
||||
Admin = "../src/Admin"
|
||||
Api = "../src/Api"
|
||||
Billing = "../src/Billing"
|
||||
Events = "../src/Events"
|
||||
EventsProcessor = "../src/EventsProcessor"
|
||||
Icons = "../src/Icons"
|
||||
Identity = "../src/Identity"
|
||||
Notifications = "../src/Notifications"
|
||||
Sso = "../bitwarden_license/src/Sso"
|
||||
Scim = "../bitwarden_license/src/Scim"
|
||||
IntegrationTests = "../test/Infrastructure.IntegrationTest"
|
||||
SeederApi = "../util/SeederApi"
|
||||
}
|
||||
|
||||
foreach ($key in $projects.keys) {
|
||||
|
||||
@@ -41,7 +41,7 @@ $migrationPath = "util/Migrator/DbScripts"
|
||||
|
||||
# Get list of migrations from base reference
|
||||
try {
|
||||
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
|
||||
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/" 2>$null | Where-Object { $_ -like "*.sql" } | Sort-Object
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
|
||||
$baseMigrations = @()
|
||||
@@ -53,7 +53,7 @@ catch {
|
||||
}
|
||||
|
||||
# Get list of migrations from current reference
|
||||
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
|
||||
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/" | Where-Object { $_ -like "*.sql" } | Sort-Object
|
||||
|
||||
# Find added migrations
|
||||
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Admin</UserSecretsId>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Admin' " />
|
||||
|
||||
@@ -496,6 +496,7 @@ public class OrganizationsController : Controller
|
||||
organization.UseOrganizationDomains = model.UseOrganizationDomains;
|
||||
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
||||
organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers;
|
||||
organization.UsePhishingBlocker = model.UsePhishingBlocker;
|
||||
|
||||
//secrets
|
||||
|
||||
@@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
|
||||
UseOrganizationDomains = org.UseOrganizationDomains;
|
||||
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
||||
UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers;
|
||||
UsePhishingBlocker = org.UsePhishingBlocker;
|
||||
|
||||
_plans = plans;
|
||||
@@ -196,6 +197,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
public int? MaxAutoscaleSmServiceAccounts { get; set; }
|
||||
[Display(Name = "Use Organization Domains")]
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
[Display(Name = "Disable SM Ads For Users")]
|
||||
public new bool UseDisableSmAdsForUsers { get; set; }
|
||||
|
||||
[Display(Name = "Automatic User Confirmation")]
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
@@ -330,6 +333,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
existingOrganization.SmServiceAccounts = SmServiceAccounts;
|
||||
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
||||
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
||||
existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers;
|
||||
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
|
||||
return existingOrganization;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ public class OrganizationViewModel
|
||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||
public bool UseRiskInsights => Organization.UseRiskInsights;
|
||||
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
|
||||
public bool UseDisableSmAdsForUsers => Organization.UseDisableSmAdsForUsers;
|
||||
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
||||
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
||||
}
|
||||
|
||||
@@ -185,6 +185,13 @@
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseSecretsManager" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseSecretsManager"></label>
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.SM1719_RemoveSecretsManagerAds))
|
||||
{
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseDisableSmAdsForUsers" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseDisableSmAdsForUsers"></label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<h3>Access Intelligence</h3>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data.Common;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Admin.HostedServices;
|
||||
|
||||
@@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
// TODO: Maybe flip a flag somewhere to indicate migration is complete??
|
||||
break;
|
||||
}
|
||||
catch (SqlException e)
|
||||
catch (DbException e)
|
||||
{
|
||||
if (i >= maxMigrationAttempts)
|
||||
{
|
||||
@@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
else
|
||||
{
|
||||
_logger.LogError(e,
|
||||
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
||||
"Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1);
|
||||
await Task.Delay(20000, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ public class Startup
|
||||
default:
|
||||
break;
|
||||
}
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
94
src/Admin/package-lock.json
generated
94
src/Admin/package-lock.json
generated
@@ -18,9 +18,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -750,9 +750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||
"version": "2.9.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
|
||||
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -793,9 +793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -814,11 +814,11 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -835,9 +835,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001763",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
|
||||
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -989,9 +989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1023,9 +1023,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1419,13 +1419,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -1542,9 +1546,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1875,9 +1879,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.93.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
|
||||
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
|
||||
"version": "1.97.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
|
||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2110,9 +2114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"version": "5.3.16",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
|
||||
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2174,9 +2178,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2226,9 +2230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2241,21 +2245,21 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
"enhanced-resolve": "^5.17.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"json-parse-even-better-errors": "^2.3.1",
|
||||
"loader-runner": "^4.2.0",
|
||||
"loader-runner": "^4.3.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
},
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Bit.Core.Context;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Authorization.Requirements;
|
||||
|
||||
/// <summary>
|
||||
/// Requires that the user is a member of the organization.
|
||||
/// </summary>
|
||||
public class MemberRequirement : IOrganizationRequirement
|
||||
{
|
||||
public Task<bool> AuthorizeAsync(
|
||||
CurrentContextOrganization? organizationClaims,
|
||||
Func<Task<bool>> isProviderUserForOrg)
|
||||
=> Task.FromResult(organizationClaims is not null);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
|
||||
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.SelfRevokeUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@@ -81,6 +82,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
|
||||
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
|
||||
|
||||
public OrganizationUsersController(IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -112,7 +114,8 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
|
||||
IAdminRecoverAccountCommand adminRecoverAccountCommand,
|
||||
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
|
||||
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
|
||||
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext,
|
||||
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -145,6 +148,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
|
||||
_adminRecoverAccountCommand = adminRecoverAccountCommand;
|
||||
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -635,6 +639,20 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync);
|
||||
}
|
||||
|
||||
[HttpPut("revoke-self")]
|
||||
[Authorize<MemberRequirement>]
|
||||
public async Task<IResult> RevokeSelfAsync(Guid orgId)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var result = await _selfRevokeOrganizationUserCommand.SelfRevokeUserAsync(orgId, userId.Value);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/revoke")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
@@ -647,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
|
||||
}
|
||||
|
||||
var currentUserId = _userService.GetProperUserId(User);
|
||||
if (currentUserId == null)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
@@ -212,7 +211,6 @@ public class PoliciesController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{type}/vnext")]
|
||||
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
|
||||
[Authorize<ManagePoliciesRequirement>]
|
||||
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
||||
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
|
||||
UseSecretsManager = organizationDetails.UseSecretsManager;
|
||||
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
|
||||
UseDisableSMAdsForUsers = organizationDetails.UseDisableSMAdsForUsers;
|
||||
UsePasswordManager = organizationDetails.UsePasswordManager;
|
||||
SelfHost = organizationDetails.SelfHost;
|
||||
Seats = organizationDetails.Seats;
|
||||
@@ -100,6 +101,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSMAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
public bool SelfHost { get; set; }
|
||||
public int? Seats { get; set; }
|
||||
|
||||
@@ -74,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
@@ -124,6 +125,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSmAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -38,7 +38,9 @@ public class AccountsController : Controller
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1;
|
||||
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
|
||||
private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand;
|
||||
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
@@ -54,6 +56,8 @@ public class AccountsController : Controller
|
||||
IUserService userService,
|
||||
IPolicyService policyService,
|
||||
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
|
||||
ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1,
|
||||
ITdeSetPasswordCommand tdeSetPasswordCommand,
|
||||
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IFeatureService featureService,
|
||||
@@ -69,6 +73,8 @@ public class AccountsController : Controller
|
||||
_userService = userService;
|
||||
_policyService = policyService;
|
||||
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
|
||||
_setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1;
|
||||
_tdeSetPasswordCommand = tdeSetPasswordCommand;
|
||||
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_featureService = featureService;
|
||||
@@ -208,7 +214,7 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("set-password")]
|
||||
public async Task PostSetPasswordAsync([FromBody] SetPasswordRequestModel model)
|
||||
public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
@@ -216,33 +222,48 @@ public class AccountsController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
try
|
||||
if (model.IsV2Request())
|
||||
{
|
||||
user = model.ToUser(user);
|
||||
if (model.IsTdeSetPasswordRequest())
|
||||
{
|
||||
await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData());
|
||||
}
|
||||
else
|
||||
{
|
||||
await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, model.ToData());
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, e.Message);
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
|
||||
try
|
||||
{
|
||||
user = model.ToUser(user);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, e.Message);
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(
|
||||
user,
|
||||
model.MasterPasswordHash,
|
||||
model.Key,
|
||||
model.OrgIdentifier);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
|
||||
user,
|
||||
model.MasterPasswordHash,
|
||||
model.Key,
|
||||
model.OrgIdentifier);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[HttpPost("verify-password")]
|
||||
|
||||
@@ -21,7 +21,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Bit.Api.Auth.Controllers;
|
||||
|
||||
[Route("webauthn")]
|
||||
[Authorize(Policies.Web)]
|
||||
public class WebAuthnController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
@@ -62,6 +61,7 @@ public class WebAuthnController : Controller
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<WebAuthnCredentialResponseModel>> Get()
|
||||
{
|
||||
@@ -71,6 +71,7 @@ public class WebAuthnController : Controller
|
||||
return new ListResponseModel<WebAuthnCredentialResponseModel>(credentials.Select(c => new WebAuthnCredentialResponseModel(c)));
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("attestation-options")]
|
||||
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -88,6 +89,7 @@ public class WebAuthnController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpPost("assertion-options")]
|
||||
public async Task<WebAuthnLoginAssertionOptionsResponseModel> AssertionOptions([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -104,6 +106,7 @@ public class WebAuthnController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("")]
|
||||
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
|
||||
{
|
||||
@@ -149,6 +152,7 @@ public class WebAuthnController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut()]
|
||||
public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
|
||||
{
|
||||
@@ -172,6 +176,7 @@ public class WebAuthnController : Controller
|
||||
await _credentialRepository.UpdateAsync(credential);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpPost("{id}/delete")]
|
||||
public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.KeyManagement.Models.Requests;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Request.Accounts;
|
||||
|
||||
public class SetInitialPasswordRequestModel : IValidatableObject
|
||||
{
|
||||
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27327
|
||||
[Obsolete("Use MasterPasswordAuthentication instead")]
|
||||
[StringLength(300)]
|
||||
public string? MasterPasswordHash { get; set; }
|
||||
|
||||
[Obsolete("Use MasterPasswordUnlock instead")]
|
||||
public string? Key { get; set; }
|
||||
|
||||
[Obsolete("Use AccountKeys instead")]
|
||||
public KeysRequestModel? Keys { get; set; }
|
||||
|
||||
[Obsolete("Use MasterPasswordAuthentication instead")]
|
||||
public KdfType? Kdf { get; set; }
|
||||
|
||||
[Obsolete("Use MasterPasswordAuthentication instead")]
|
||||
public int? KdfIterations { get; set; }
|
||||
|
||||
[Obsolete("Use MasterPasswordAuthentication instead")]
|
||||
public int? KdfMemory { get; set; }
|
||||
|
||||
[Obsolete("Use MasterPasswordAuthentication instead")]
|
||||
public int? KdfParallelism { get; set; }
|
||||
|
||||
public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; }
|
||||
public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; }
|
||||
public AccountKeysRequestModel? AccountKeys { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? MasterPasswordHint { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string OrgIdentifier { get; set; }
|
||||
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
|
||||
public User ToUser(User existingUser)
|
||||
{
|
||||
existingUser.MasterPasswordHint = MasterPasswordHint;
|
||||
existingUser.Kdf = Kdf!.Value;
|
||||
existingUser.KdfIterations = KdfIterations!.Value;
|
||||
existingUser.KdfMemory = KdfMemory;
|
||||
existingUser.KdfParallelism = KdfParallelism;
|
||||
existingUser.Key = Key;
|
||||
Keys?.ToUser(existingUser);
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (IsV2Request())
|
||||
{
|
||||
// V2 registration
|
||||
|
||||
// Validate Kdf
|
||||
var authenticationKdf = MasterPasswordAuthentication!.Kdf.ToData();
|
||||
var unlockKdf = MasterPasswordUnlock!.Kdf.ToData();
|
||||
|
||||
// Currently, KDF settings are not saved separately for authentication and unlock and must therefore be equal
|
||||
if (!authenticationKdf.Equals(unlockKdf))
|
||||
{
|
||||
yield return new ValidationResult("KDF settings must be equal for authentication and unlock.",
|
||||
[$"{nameof(MasterPasswordAuthentication)}.{nameof(MasterPasswordAuthenticationDataRequestModel.Kdf)}",
|
||||
$"{nameof(MasterPasswordUnlock)}.{nameof(MasterPasswordUnlockDataRequestModel.Kdf)}"]);
|
||||
}
|
||||
|
||||
var authenticationValidationErrors = KdfSettingsValidator.Validate(authenticationKdf).ToList();
|
||||
if (authenticationValidationErrors.Count != 0)
|
||||
{
|
||||
yield return authenticationValidationErrors.First();
|
||||
}
|
||||
|
||||
var unlockValidationErrors = KdfSettingsValidator.Validate(unlockKdf).ToList();
|
||||
if (unlockValidationErrors.Count != 0)
|
||||
{
|
||||
yield return unlockValidationErrors.First();
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
// V1 registration
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
|
||||
if (string.IsNullOrEmpty(MasterPasswordHash))
|
||||
{
|
||||
yield return new ValidationResult("MasterPasswordHash must be supplied.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Key))
|
||||
{
|
||||
yield return new ValidationResult("Key must be supplied.");
|
||||
}
|
||||
|
||||
if (Kdf == null)
|
||||
{
|
||||
yield return new ValidationResult("Kdf must be supplied.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (KdfIterations == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfIterations must be supplied.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (Kdf == KdfType.Argon2id)
|
||||
{
|
||||
if (KdfMemory == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
|
||||
if (KdfParallelism == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
}
|
||||
|
||||
var validationErrors = KdfSettingsValidator
|
||||
.Validate(Kdf!.Value, KdfIterations!.Value, KdfMemory, KdfParallelism).ToList();
|
||||
if (validationErrors.Count != 0)
|
||||
{
|
||||
yield return validationErrors.First();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsV2Request()
|
||||
{
|
||||
// AccountKeys can be null for TDE users, so we don't check that here
|
||||
return MasterPasswordAuthentication != null && MasterPasswordUnlock != null;
|
||||
}
|
||||
|
||||
public bool IsTdeSetPasswordRequest()
|
||||
{
|
||||
return AccountKeys == null;
|
||||
}
|
||||
|
||||
public SetInitialMasterPasswordDataModel ToData()
|
||||
{
|
||||
return new SetInitialMasterPasswordDataModel
|
||||
{
|
||||
MasterPasswordAuthentication = MasterPasswordAuthentication!.ToData(),
|
||||
MasterPasswordUnlock = MasterPasswordUnlock!.ToData(),
|
||||
OrgSsoIdentifier = OrgIdentifier,
|
||||
AccountKeys = AccountKeys?.ToAccountKeysData(),
|
||||
MasterPasswordHint = MasterPasswordHint
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Request.Accounts;
|
||||
|
||||
public class SetPasswordRequestModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string MasterPasswordHash { get; set; }
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
[StringLength(50)]
|
||||
public string MasterPasswordHint { get; set; }
|
||||
public KeysRequestModel Keys { get; set; }
|
||||
[Required]
|
||||
public KdfType Kdf { get; set; }
|
||||
[Required]
|
||||
public int KdfIterations { get; set; }
|
||||
public int? KdfMemory { get; set; }
|
||||
public int? KdfParallelism { get; set; }
|
||||
public string OrgIdentifier { get; set; }
|
||||
|
||||
public User ToUser(User existingUser)
|
||||
{
|
||||
existingUser.MasterPasswordHint = MasterPasswordHint;
|
||||
existingUser.Kdf = Kdf;
|
||||
existingUser.KdfIterations = KdfIterations;
|
||||
existingUser.KdfMemory = KdfMemory;
|
||||
existingUser.KdfParallelism = KdfParallelism;
|
||||
existingUser.Key = Key;
|
||||
Keys?.ToUser(existingUser);
|
||||
return existingUser;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
|
||||
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
|
||||
}
|
||||
existingEmergencyAccess.Type = Type;
|
||||
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
|
||||
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
|
||||
return existingEmergencyAccess;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ using Bit.Api.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
@@ -22,60 +19,10 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class AccountsController(
|
||||
IUserService userService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IFeatureService featureService,
|
||||
ILicensingService licensingService) : Controller
|
||||
{
|
||||
// TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed
|
||||
[HttpPost("premium")]
|
||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||
PremiumRequestModel model,
|
||||
[FromServices] GlobalSettings globalSettings)
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var valid = model.Validate(globalSettings);
|
||||
UserLicense? license = null;
|
||||
if (valid && globalSettings.SelfHosted)
|
||||
{
|
||||
license = await ApiHelpers.ReadJsonFileFromBody<UserLicense>(HttpContext, model.License);
|
||||
}
|
||||
|
||||
if (!valid && !globalSettings.SelfHosted && string.IsNullOrWhiteSpace(model.Country))
|
||||
{
|
||||
throw new BadRequestException("Country is required.");
|
||||
}
|
||||
|
||||
if (!valid || (globalSettings.SelfHosted && license == null))
|
||||
{
|
||||
throw new BadRequestException("Invalid license.");
|
||||
}
|
||||
|
||||
var result = await userService.SignUpPremiumAsync(user, model.PaymentToken,
|
||||
model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license,
|
||||
new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode });
|
||||
|
||||
var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
||||
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||
var accountKeys = await userAccountKeysQuery.Run(user);
|
||||
|
||||
var profile = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled,
|
||||
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||
return new PaymentResponseModel
|
||||
{
|
||||
UserProfile = profile,
|
||||
PaymentIntentClientSecret = result.Item2,
|
||||
Success = result.Item1
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpGet("subscription")]
|
||||
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
|
||||
[FromServices] GlobalSettings globalSettings,
|
||||
@@ -114,7 +61,7 @@ public class AccountsController(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpPost("storage")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
|
||||
@@ -171,7 +118,7 @@ public class AccountsController(
|
||||
user.IsExpired());
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpPost("reinstate-premium")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostReinstateAsync()
|
||||
@@ -184,10 +131,4 @@ public class AccountsController(
|
||||
|
||||
await userService.ReinstatePremiumAsync(user);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||
{
|
||||
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
|
||||
return organizationsClaimingUser.Select(o => o.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Api.Billing.Models.Requests.Premium;
|
||||
using Bit.Api.Billing.Models.Requests.Storage;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Licenses.Queries;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -19,9 +23,14 @@ namespace Bit.Api.Billing.Controllers.VNext;
|
||||
public class AccountBillingVNextController(
|
||||
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
|
||||
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
|
||||
IGetCreditQuery getCreditQuery,
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
|
||||
IGetUserLicenseQuery getUserLicenseQuery,
|
||||
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
|
||||
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
|
||||
{
|
||||
[HttpGet("credit")]
|
||||
[InjectUser]
|
||||
@@ -66,7 +75,6 @@ public class AccountBillingVNextController(
|
||||
}
|
||||
|
||||
[HttpPost("subscription")]
|
||||
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> CreateSubscriptionAsync(
|
||||
[BindNever] User user,
|
||||
@@ -77,4 +85,55 @@ public class AccountBillingVNextController(
|
||||
user, paymentMethod, billingAddress, additionalStorageGb);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpGet("license")]
|
||||
[InjectUser]
|
||||
public async Task<IResult> GetLicenseAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var response = await getUserLicenseQuery.Run(user);
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("subscription")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> GetSubscriptionAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var subscription = await getBitwardenSubscriptionQuery.Run(user);
|
||||
return TypedResults.Ok(subscription);
|
||||
}
|
||||
|
||||
[HttpPost("subscription/reinstate")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> ReinstateSubscriptionAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var result = await reinstateSubscriptionCommand.Run(user);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpPut("subscription/storage")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpdateSubscriptionStorageAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] StorageUpdateRequest request)
|
||||
{
|
||||
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpPost("upgrade")]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpgradePremiumToOrganizationAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] UpgradePremiumToOrganizationRequest request)
|
||||
{
|
||||
var (organizationName, key, planType) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
|
||||
return Handle(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Premium;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Entities;
|
||||
@@ -20,7 +19,6 @@ public class SelfHostedAccountBillingVNextController(
|
||||
ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController
|
||||
{
|
||||
[HttpPost("license")]
|
||||
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UploadLicenseAsync(
|
||||
[BindNever] User user,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Premium;
|
||||
|
||||
public class UpgradePremiumToOrganizationRequest
|
||||
{
|
||||
[Required]
|
||||
public string OrganizationName { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
public string Key { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public ProductTierType Tier { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public PlanCadenceType Cadence { get; set; }
|
||||
|
||||
private PlanType PlanType =>
|
||||
Tier switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.TeamsMonthly
|
||||
: PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.EnterpriseMonthly
|
||||
: PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
|
||||
};
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for updating storage allocation on a user's premium subscription.
|
||||
/// Allows for both increasing and decreasing storage in an idempotent manner.
|
||||
/// </summary>
|
||||
public class StorageUpdateRequest : IValidatableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The additional storage in GB beyond the base storage.
|
||||
/// Must be between 0 and the maximum allowed (minus base storage).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public short AdditionalStorageGb { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (AdditionalStorageGb < 0)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Additional storage cannot be negative.",
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
|
||||
if (AdditionalStorageGb > 99)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Maximum additional storage is 99 GB.",
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
|
||||
using Bit.Api.Dirt.Models.Request;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,12 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Api.Dirt.Models.Request;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations/{organizationId:guid}/integrations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,16 +1,16 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Dirt.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Dirt.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,8 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Request;
|
||||
|
||||
public class OrganizationIntegrationConfigurationRequestModel
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Request;
|
||||
|
||||
public class OrganizationIntegrationRequestModel : IValidatableObject
|
||||
{
|
||||
@@ -1,8 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class OrganizationIntegrationResponseModel : ResponseModel
|
||||
{
|
||||
@@ -6,8 +6,11 @@ namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
public class MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
public required KdfRequestModel Kdf { get; init; }
|
||||
[Required]
|
||||
public required string MasterPasswordAuthenticationHash { get; init; }
|
||||
[StringLength(256)] public required string Salt { get; init; }
|
||||
[Required]
|
||||
[StringLength(256)]
|
||||
public required string Salt { get; init; }
|
||||
|
||||
public MasterPasswordAuthenticationData ToData()
|
||||
{
|
||||
|
||||
@@ -7,8 +7,12 @@ namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
public class MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
public required KdfRequestModel Kdf { get; init; }
|
||||
[EncryptedString] public required string MasterKeyWrappedUserKey { get; init; }
|
||||
[StringLength(256)] public required string Salt { get; init; }
|
||||
[Required]
|
||||
[EncryptedString]
|
||||
public required string MasterKeyWrappedUserKey { get; init; }
|
||||
[Required]
|
||||
[StringLength(256)]
|
||||
public required string Salt { get; init; }
|
||||
|
||||
public MasterPasswordUnlockData ToData()
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRe
|
||||
throw new BadRequestException("All existing sends must be included in the rotation.");
|
||||
}
|
||||
|
||||
result.Add(send.ToSend(existing, _sendAuthorizationService));
|
||||
result.Add(send.UpdateSend(existing, _sendAuthorizationService));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -10,6 +10,7 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Models.Response;
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
public class SubscriptionResponseModel : ResponseModel
|
||||
{
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Bit.Api.Tools.Authorization;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -21,7 +20,6 @@ public class OrganizationExportController : Controller
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationExportController(
|
||||
IUserService userService,
|
||||
@@ -36,7 +34,6 @@ public class OrganizationExportController : Controller
|
||||
_authorizationService = authorizationService;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_collectionRepository = collectionRepository;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("export")]
|
||||
@@ -46,33 +43,20 @@ public class OrganizationExportController : Controller
|
||||
VaultExportOperations.ExportWholeVault);
|
||||
var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId),
|
||||
VaultExportOperations.ExportManagedCollections);
|
||||
var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation);
|
||||
|
||||
if (canExportAll.Succeeded)
|
||||
{
|
||||
if (createDefaultLocationEnabled)
|
||||
{
|
||||
var allOrganizationCiphers =
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
|
||||
organizationId);
|
||||
var allOrganizationCiphers =
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
|
||||
organizationId);
|
||||
|
||||
var allCollections = await _collectionRepository
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
organizationId);
|
||||
var allCollections = await _collectionRepository
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
organizationId);
|
||||
|
||||
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
else
|
||||
{
|
||||
var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId);
|
||||
|
||||
var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
|
||||
if (canExportManaged.Succeeded)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Azure.Messaging.EventGrid;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Auth.UserFeatures.SendAccess;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Tools.SendFeatures;
|
||||
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
|
||||
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -24,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Bit.Api.Tools.Controllers;
|
||||
|
||||
[Route("sends")]
|
||||
[Authorize("Application")]
|
||||
public class SendsController : Controller
|
||||
{
|
||||
private readonly ISendRepository _sendRepository;
|
||||
@@ -33,8 +32,10 @@ public class SendsController : Controller
|
||||
private readonly ISendFileStorageService _sendFileStorageService;
|
||||
private readonly IAnonymousSendCommand _anonymousSendCommand;
|
||||
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
|
||||
private readonly ISendOwnerQuery _sendOwnerQuery;
|
||||
private readonly ILogger<SendsController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public SendsController(
|
||||
ISendRepository sendRepository,
|
||||
@@ -42,21 +43,26 @@ public class SendsController : Controller
|
||||
ISendAuthorizationService sendAuthorizationService,
|
||||
IAnonymousSendCommand anonymousSendCommand,
|
||||
INonAnonymousSendCommand nonAnonymousSendCommand,
|
||||
ISendOwnerQuery sendOwnerQuery,
|
||||
ISendFileStorageService sendFileStorageService,
|
||||
ILogger<SendsController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
IFeatureService featureService,
|
||||
IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_sendRepository = sendRepository;
|
||||
_userService = userService;
|
||||
_sendAuthorizationService = sendAuthorizationService;
|
||||
_anonymousSendCommand = anonymousSendCommand;
|
||||
_nonAnonymousSendCommand = nonAnonymousSendCommand;
|
||||
_sendOwnerQuery = sendOwnerQuery;
|
||||
_sendFileStorageService = sendFileStorageService;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
}
|
||||
|
||||
#region Anonymous endpoints
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("access/{id}")]
|
||||
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
|
||||
@@ -70,28 +76,44 @@ public class SendsController : Controller
|
||||
|
||||
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
SendAccessResult sendAuthResult =
|
||||
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
/* This guard can be removed once feature flag is retired*/
|
||||
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
|
||||
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
var sendAuthResult =
|
||||
await _sendAuthorizationService.AccessAsync(send, model.Password);
|
||||
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid password.");
|
||||
}
|
||||
|
||||
if (sendAuthResult.Equals(SendAccessResult.Denied))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var sendResponse = new SendAccessResponseModel(send, _globalSettings);
|
||||
var sendResponse = new SendAccessResponseModel(send);
|
||||
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
|
||||
{
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
|
||||
@@ -115,6 +137,13 @@ public class SendsController : Controller
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
/* This guard can be removed once feature flag is retired*/
|
||||
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
|
||||
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
|
||||
model.Password);
|
||||
|
||||
@@ -122,21 +151,19 @@ public class SendsController : Controller
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
if (result.Equals(SendAccessResult.PasswordInvalid))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid password.");
|
||||
}
|
||||
|
||||
if (result.Equals(SendAccessResult.Denied))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel()
|
||||
{
|
||||
Id = fileId,
|
||||
Url = url,
|
||||
});
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -150,7 +177,8 @@ public class SendsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
|
||||
var blobName =
|
||||
eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
|
||||
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
|
||||
if (send == null)
|
||||
@@ -159,6 +187,7 @@ public class SendsController : Controller
|
||||
{
|
||||
await azureSendFileStorageService.DeleteBlobAsync(blobName);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,7 +195,8 @@ public class SendsController : Controller
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
|
||||
JsonSerializer.Serialize(eventGridEvent));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -178,38 +208,98 @@ public class SendsController : Controller
|
||||
|
||||
#region Non-anonymous endpoints
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}")]
|
||||
public async Task<SendResponseModel> Get(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
var sendId = new Guid(id);
|
||||
var send = await _sendOwnerQuery.Get(sendId, User);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<SendResponseModel>> GetAll()
|
||||
{
|
||||
var sends = await _sendOwnerQuery.GetOwned(User);
|
||||
var responses = sends.Select(s => new SendResponseModel(s));
|
||||
var result = new ListResponseModel<SendResponseModel>(responses);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.Send)]
|
||||
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
|
||||
[HttpPost("access/")]
|
||||
public async Task<IActionResult> AccessUsingAuth()
|
||||
{
|
||||
var guid = User.GetSendId();
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
if (send == null)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
var sendResponse = new SendAccessResponseModel(send);
|
||||
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
|
||||
{
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<SendResponseModel>> GetAll()
|
||||
[Authorize(Policy = Policies.Send)]
|
||||
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
|
||||
[HttpPost("access/file/{fileId}")]
|
||||
public async Task<IActionResult> GetSendFileDownloadDataUsingAuth(string fileId)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var sends = await _sendRepository.GetManyByUserIdAsync(userId);
|
||||
var responses = sends.Select(s => new SendResponseModel(s, _globalSettings));
|
||||
return new ListResponseModel<SendResponseModel>(responses);
|
||||
var sendId = User.GetSendId();
|
||||
var send = await _sendRepository.GetByIdAsync(sendId);
|
||||
|
||||
if (send == null)
|
||||
{
|
||||
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);
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("")]
|
||||
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
|
||||
{
|
||||
model.ValidateCreation();
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = model.ToSend(userId, _sendAuthorizationService);
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("file/v2")]
|
||||
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -229,27 +319,28 @@ public class SendsController : Controller
|
||||
}
|
||||
|
||||
model.ValidateCreation();
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService);
|
||||
var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value);
|
||||
return new SendFileUploadDataResponseModel
|
||||
{
|
||||
Url = uploadUrl,
|
||||
FileUploadType = _sendFileStorageService.FileUploadType,
|
||||
SendResponse = new SendResponseModel(send, _globalSettings)
|
||||
SendResponse = new SendResponseModel(send)
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}/file/{fileId}")]
|
||||
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var sendId = new Guid(id);
|
||||
var send = await _sendRepository.GetByIdAsync(sendId);
|
||||
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data);
|
||||
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data ?? string.Empty);
|
||||
|
||||
if (send == null || send.Type != SendType.File || (send.UserId.HasValue && send.UserId.Value != userId) ||
|
||||
!send.UserId.HasValue || fileData.Id != fileId || fileData.Validated)
|
||||
!send.UserId.HasValue || fileData?.Id != fileId || fileData.Validated)
|
||||
{
|
||||
// Not found if Send isn't found, user doesn't have access, request is faulty,
|
||||
// or we've already validated the file. This last is to emulate create-only blob permissions for Azure
|
||||
@@ -260,62 +351,95 @@ public class SendsController : Controller
|
||||
{
|
||||
Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId),
|
||||
FileUploadType = _sendFileStorageService.FileUploadType,
|
||||
SendResponse = new SendResponseModel(send, _globalSettings),
|
||||
SendResponse = new SendResponseModel(send),
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("{id}/file/{fileId}")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
[RequestSizeLimit(Constants.FileSize501mb)]
|
||||
[DisableFormValueModelBinding]
|
||||
public async Task PostFileForExistingSend(string id, string fileId)
|
||||
{
|
||||
if (!Request?.ContentType.Contains("multipart/") ?? true)
|
||||
if (!Request?.ContentType?.Contains("multipart/") ?? true)
|
||||
{
|
||||
throw new BadRequestException("Invalid content.");
|
||||
}
|
||||
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
await Request.GetFileAsync(async (stream) =>
|
||||
{
|
||||
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}")]
|
||||
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
|
||||
{
|
||||
model.ValidateEdit();
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService));
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(model.UpdateSend(send, _sendAuthorizationService));
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}/remove-password")]
|
||||
public async Task<SendResponseModel> PutRemovePassword(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
|
||||
// This allows clients to update other fields without re-submitting sensitive auth data.
|
||||
send.Password = null;
|
||||
send.AuthType = AuthType.None;
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
// Removes ALL authentication (email or password) if any is present
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}/remove-auth")]
|
||||
public async Task<SendResponseModel> PutRemoveAuth(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
|
||||
// This allows clients to update other fields without re-submitting sensitive auth data.
|
||||
send.Password = null;
|
||||
send.Emails = null;
|
||||
send.AuthType = AuthType.None;
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpDelete("{id}")]
|
||||
public async Task Delete(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.Tools.Utilities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
@@ -10,35 +11,119 @@ using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using static System.StringSplitOptions;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Request;
|
||||
|
||||
/// <summary>
|
||||
/// A send request issued by a Bitwarden client
|
||||
/// </summary>
|
||||
public class SendRequestModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the send contains text or file data.
|
||||
/// </summary>
|
||||
public SendType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the authentication method required to access this Send.
|
||||
/// </summary>
|
||||
public AuthType? AuthType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated length of the file accompanying the send. <see langword="null"/> when
|
||||
/// <see cref="Type"/> is <see cref="SendType.Text"/>.
|
||||
/// </summary>
|
||||
public long? FileLength { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Label for the send.
|
||||
/// </summary>
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Notes for the send. This is only visible to the owner of the send.
|
||||
/// </summary>
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A base64-encoded byte array containing the Send's encryption key. This key is
|
||||
/// also provided to send recipients in the Send's URL.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of times a send can be accessed before it expires.
|
||||
/// When this value is <see langword="null" />, there is no limit.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int? MaxAccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send cannot be accessed. When this value is
|
||||
/// <see langword="null"/>, there is no expiration date.
|
||||
/// </summary>
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send may be automatically deleted from the server.
|
||||
/// When this is <see langword="null" />, the send may be deleted after it has
|
||||
/// exceeded the global send timeout limit.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime? DeletionDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains file metadata uploaded with the send.
|
||||
/// The file content is uploaded separately.
|
||||
/// </summary>
|
||||
public SendFileModel File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains text data uploaded with the send.
|
||||
/// </summary>
|
||||
public SendTextModel Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded byte array of a password hash that grants access to the send.
|
||||
/// Mutually exclusive with <see cref="Emails"/>.
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of emails that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
[StringLength(4000)]
|
||||
public string Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, send access is disabled.
|
||||
/// Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool? Disabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> send access hides the user's email address
|
||||
/// and displays a confirmation message instead. Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool? HideEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the request into a send object.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user that owns the send.</param>
|
||||
/// <param name="sendAuthorizationService">Hashes the send password.</param>
|
||||
/// <returns>The send object</returns>
|
||||
public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService)
|
||||
{
|
||||
var send = new Send
|
||||
@@ -46,12 +131,21 @@ public class SendRequestModel
|
||||
Type = Type,
|
||||
UserId = (Guid?)userId
|
||||
};
|
||||
ToSend(send, sendAuthorizationService);
|
||||
send = UpdateSend(send, sendAuthorizationService);
|
||||
return send;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the request into a send object and file data.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user that owns the send.</param>
|
||||
/// <param name="fileName">Name of the file uploaded with the send.</param>
|
||||
/// <param name="sendAuthorizationService">Hashes the send password.</param>
|
||||
/// <returns>The send object and file data.</returns>
|
||||
public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService)
|
||||
{
|
||||
// FIXME: This method does two things: creates a send and a send file data.
|
||||
// It should only do one thing.
|
||||
var send = ToSendBase(new Send
|
||||
{
|
||||
Type = Type,
|
||||
@@ -61,7 +155,13 @@ public class SendRequestModel
|
||||
return (send, data);
|
||||
}
|
||||
|
||||
public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
|
||||
/// <summary>
|
||||
/// Update a send object with request content
|
||||
/// </summary>
|
||||
/// <param name="existingSend">The send to update</param>
|
||||
/// <param name="sendAuthorizationService">Hashes the send password.</param>
|
||||
/// <returns>The send object</returns>
|
||||
public Send UpdateSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
|
||||
{
|
||||
existingSend = ToSendBase(existingSend, sendAuthorizationService);
|
||||
switch (existingSend.Type)
|
||||
@@ -81,6 +181,12 @@ public class SendRequestModel
|
||||
return existingSend;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the request is internally consistent for send creation.
|
||||
/// </summary>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown when the send's expiration date has already expired.
|
||||
/// </exception>
|
||||
public void ValidateCreation()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -94,6 +200,13 @@ public class SendRequestModel
|
||||
ValidateEdit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the request is internally consistent for send administration.
|
||||
/// </summary>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown when the send's deletion date has already expired or when its
|
||||
/// expiration occurs after its deletion.
|
||||
/// </exception>
|
||||
public void ValidateEdit()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -134,12 +247,30 @@ public class SendRequestModel
|
||||
existingSend.ExpirationDate = ExpirationDate;
|
||||
existingSend.DeletionDate = DeletionDate.Value;
|
||||
existingSend.MaxAccessCount = MaxAccessCount;
|
||||
if (!string.IsNullOrWhiteSpace(Password))
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Emails))
|
||||
{
|
||||
// normalize encoding
|
||||
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
|
||||
existingSend.Emails = string.Join(",", emails);
|
||||
existingSend.Password = null;
|
||||
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
existingSend.Password = authorizationService.HashPassword(Password);
|
||||
existingSend.Emails = null;
|
||||
existingSend.AuthType = Core.Tools.Enums.AuthType.Password;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neither Password nor Emails provided - preserve existing values and infer AuthType
|
||||
existingSend.AuthType = SendUtilities.InferAuthType(existingSend);
|
||||
}
|
||||
|
||||
existingSend.Disabled = Disabled.GetValueOrDefault();
|
||||
existingSend.HideEmail = HideEmail.GetValueOrDefault();
|
||||
|
||||
return existingSend;
|
||||
}
|
||||
|
||||
@@ -149,8 +280,15 @@ public class SendRequestModel
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A send request issued by a Bitwarden client
|
||||
/// </summary>
|
||||
public class SendWithIdRequestModel : SendRequestModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the send. When this is <see langword="null" />, the client is requesting
|
||||
/// a new send.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid? Id { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
@@ -11,9 +10,22 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
/// <summary>
|
||||
/// A response issued to a Bitwarden client in response to access operations.
|
||||
/// </summary>
|
||||
public class SendAccessResponseModel : ResponseModel
|
||||
{
|
||||
public SendAccessResponseModel(Send send, GlobalSettings globalSettings)
|
||||
/// <summary>
|
||||
/// Instantiates a send access response model
|
||||
/// </summary>
|
||||
/// <param name="send">Content to transmit to the client.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="send"/> is <see langword="null" />
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
|
||||
/// </exception>
|
||||
public SendAccessResponseModel(Send send)
|
||||
: base("send-access")
|
||||
{
|
||||
if (send == null)
|
||||
@@ -23,6 +35,7 @@ public class SendAccessResponseModel : ResponseModel
|
||||
|
||||
Id = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
|
||||
Type = send.Type;
|
||||
AuthType = send.AuthType;
|
||||
|
||||
SendData sendData;
|
||||
switch (send.Type)
|
||||
@@ -45,11 +58,52 @@ public class SendAccessResponseModel : ResponseModel
|
||||
ExpirationDate = send.ExpirationDate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the send in a send URL
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the send contains text or file data.
|
||||
/// </summary>
|
||||
public SendType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the authentication method required to access this Send.
|
||||
/// </summary>
|
||||
public AuthType? AuthType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label for the send. This is only visible to the owner of the send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted encrypted content.
|
||||
/// </remarks>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Describes the file attached to the send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File content is downloaded separately using
|
||||
/// <see cref="Bit.Api.Tools.Controllers.SendsController.GetSendFileDownloadData" />
|
||||
/// </remarks>
|
||||
public SendFileModel File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains text data uploaded with the send.
|
||||
/// </summary>
|
||||
public SendTextModel Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send cannot be accessed. When this value is
|
||||
/// <see langword="null"/>, there is no expiration date.
|
||||
/// </summary>
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the person that created the send to the accessor.
|
||||
/// </summary>
|
||||
public string CreatorIdentifier { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Api.Tools.Utilities;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
@@ -11,9 +11,23 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
/// <summary>
|
||||
/// A response issued to a Bitwarden client in response to ownership operations.
|
||||
/// </summary>
|
||||
/// <seealso cref="SendAccessResponseModel" />
|
||||
public class SendResponseModel : ResponseModel
|
||||
{
|
||||
public SendResponseModel(Send send, GlobalSettings globalSettings)
|
||||
/// <summary>
|
||||
/// Instantiates a send response model
|
||||
/// </summary>
|
||||
/// <param name="send">Content to transmit to the client.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="send"/> is <see langword="null" />
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
|
||||
/// </exception>
|
||||
public SendResponseModel(Send send)
|
||||
: base("send")
|
||||
{
|
||||
if (send == null)
|
||||
@@ -24,6 +38,7 @@ public class SendResponseModel : ResponseModel
|
||||
Id = send.Id;
|
||||
AccessId = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
|
||||
Type = send.Type;
|
||||
AuthType = send.AuthType ?? SendUtilities.InferAuthType(send);
|
||||
Key = send.Key;
|
||||
MaxAccessCount = send.MaxAccessCount;
|
||||
AccessCount = send.AccessCount;
|
||||
@@ -31,6 +46,7 @@ public class SendResponseModel : ResponseModel
|
||||
ExpirationDate = send.ExpirationDate;
|
||||
DeletionDate = send.DeletionDate;
|
||||
Password = send.Password;
|
||||
Emails = send.Emails;
|
||||
Disabled = send.Disabled;
|
||||
HideEmail = send.HideEmail.GetValueOrDefault();
|
||||
|
||||
@@ -55,20 +71,113 @@ public class SendResponseModel : ResponseModel
|
||||
Notes = sendData.Notes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the send to its owner
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the send in a send URL
|
||||
/// </summary>
|
||||
public string AccessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the send contains text or file data.
|
||||
/// </summary>
|
||||
public SendType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the authentication method required to access this Send.
|
||||
/// </summary>
|
||||
public AuthType? AuthType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label for the send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted encrypted content.
|
||||
/// </remarks>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Notes for the send. This is only visible to the owner of the send.
|
||||
/// This field is encrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted encrypted content.
|
||||
/// </remarks>
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains file metadata uploaded with the send.
|
||||
/// The file content is uploaded separately.
|
||||
/// </summary>
|
||||
public SendFileModel File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains text data uploaded with the send.
|
||||
/// </summary>
|
||||
public SendTextModel Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A base64-encoded byte array containing the Send's encryption key.
|
||||
/// It's also provided to send recipients in the Send's URL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted content.
|
||||
/// </remarks>
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of times a send can be accessed before it expires.
|
||||
/// When this value is <see langword="null" />, there is no limit.
|
||||
/// </summary>
|
||||
public int? MaxAccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of times a send has been accessed since it was created.
|
||||
/// </summary>
|
||||
public int AccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded byte array of a password hash that grants access to the send.
|
||||
/// Mutually exclusive with <see cref="Emails"/>.
|
||||
/// </summary>
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of emails that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
public string Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, send access is disabled.
|
||||
/// </summary>
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The last time this send's data changed.
|
||||
/// </summary>
|
||||
public DateTime RevisionDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send cannot be accessed. When this value is
|
||||
/// <see langword="null"/>, there is no expiration date.
|
||||
/// </summary>
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send may be automatically deleted from the server.
|
||||
/// </summary>
|
||||
public DateTime DeletionDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> send access hides the user's email address
|
||||
/// and displays a confirmation message instead.
|
||||
/// </summary>
|
||||
public bool HideEmail { get; set; }
|
||||
}
|
||||
|
||||
23
src/Api/Tools/Utilities/InferAuthType.cs
Normal file
23
src/Api/Tools/Utilities/InferAuthType.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Bit.Api.Tools.Utilities;
|
||||
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
|
||||
public class SendUtilities
|
||||
{
|
||||
public static AuthType InferAuthType(Send send)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(send.Password))
|
||||
{
|
||||
return AuthType.Password;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(send.Emails))
|
||||
{
|
||||
return AuthType.Email;
|
||||
}
|
||||
|
||||
return AuthType.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ using Bit.Api.Utilities;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -43,7 +42,6 @@ public class CiphersController : Controller
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAttachmentStorageService _attachmentStorageService;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<CiphersController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
@@ -52,7 +50,6 @@ public class CiphersController : Controller
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IArchiveCiphersCommand _archiveCiphersCommand;
|
||||
private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public CiphersController(
|
||||
ICipherRepository cipherRepository,
|
||||
@@ -60,7 +57,6 @@ public class CiphersController : Controller
|
||||
ICipherService cipherService,
|
||||
IUserService userService,
|
||||
IAttachmentStorageService attachmentStorageService,
|
||||
IProviderService providerService,
|
||||
ICurrentContext currentContext,
|
||||
ILogger<CiphersController> logger,
|
||||
GlobalSettings globalSettings,
|
||||
@@ -68,15 +64,13 @@ public class CiphersController : Controller
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICollectionRepository collectionRepository,
|
||||
IArchiveCiphersCommand archiveCiphersCommand,
|
||||
IUnarchiveCiphersCommand unarchiveCiphersCommand,
|
||||
IFeatureService featureService)
|
||||
IUnarchiveCiphersCommand unarchiveCiphersCommand)
|
||||
{
|
||||
_cipherRepository = cipherRepository;
|
||||
_collectionCipherRepository = collectionCipherRepository;
|
||||
_cipherService = cipherService;
|
||||
_userService = userService;
|
||||
_attachmentStorageService = attachmentStorageService;
|
||||
_providerService = providerService;
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
@@ -85,7 +79,6 @@ public class CiphersController : Controller
|
||||
_collectionRepository = collectionRepository;
|
||||
_archiveCiphersCommand = archiveCiphersCommand;
|
||||
_unarchiveCiphersCommand = unarchiveCiphersCommand;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -344,8 +337,7 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems;
|
||||
var allOrganizationCiphers = excludeDefaultUserCollections
|
||||
var allOrganizationCiphers = !includeMemberItems
|
||||
?
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId)
|
||||
:
|
||||
@@ -911,7 +903,7 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpPut("{id}/archive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<CipherMiniResponseModel> PutArchive(Guid id)
|
||||
public async Task<CipherResponseModel> PutArchive(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
@@ -922,12 +914,16 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it.");
|
||||
}
|
||||
|
||||
return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp);
|
||||
return new CipherResponseModel(archivedCipherOrganizationDetails.First(),
|
||||
await _userService.GetUserByPrincipalAsync(User),
|
||||
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
|
||||
_globalSettings
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPut("archive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<ListResponseModel<CipherMiniResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
|
||||
public async Task<ListResponseModel<CipherResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
|
||||
{
|
||||
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
|
||||
{
|
||||
@@ -935,6 +931,7 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
|
||||
var cipherIdsToArchive = new HashSet<Guid>(model.Ids);
|
||||
|
||||
@@ -945,9 +942,14 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them.");
|
||||
}
|
||||
|
||||
var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
|
||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
var responses = archivedCiphers.Select(c => new CipherResponseModel(c,
|
||||
user,
|
||||
organizationAbilities,
|
||||
_globalSettings
|
||||
));
|
||||
|
||||
return new ListResponseModel<CipherMiniResponseModel>(responses);
|
||||
return new ListResponseModel<CipherResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
@@ -1109,7 +1111,7 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpPut("{id}/unarchive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<CipherMiniResponseModel> PutUnarchive(Guid id)
|
||||
public async Task<CipherResponseModel> PutUnarchive(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
@@ -1120,12 +1122,16 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
|
||||
}
|
||||
|
||||
return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp);
|
||||
return new CipherResponseModel(unarchivedCipherDetails.First(),
|
||||
await _userService.GetUserByPrincipalAsync(User),
|
||||
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
|
||||
_globalSettings
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPut("unarchive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<ListResponseModel<CipherMiniResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
|
||||
public async Task<ListResponseModel<CipherResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
|
||||
{
|
||||
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
|
||||
{
|
||||
@@ -1133,6 +1139,8 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
|
||||
var cipherIdsToUnarchive = new HashSet<Guid>(model.Ids);
|
||||
|
||||
@@ -1143,9 +1151,9 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
|
||||
}
|
||||
|
||||
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
|
||||
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherResponseModel(c, user, organizationAbilities, _globalSettings));
|
||||
|
||||
return new ListResponseModel<CipherMiniResponseModel>(responses);
|
||||
return new ListResponseModel<CipherResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/restore")]
|
||||
|
||||
@@ -80,6 +80,7 @@ public class CipherRequestModel
|
||||
{
|
||||
existingCipher.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : (Guid?)new Guid(FolderId);
|
||||
existingCipher.Favorite = Favorite;
|
||||
existingCipher.ArchivedDate = ArchivedDate;
|
||||
ToCipher(existingCipher);
|
||||
return existingCipher;
|
||||
}
|
||||
@@ -127,9 +128,9 @@ public class CipherRequestModel
|
||||
var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null;
|
||||
existingCipher.Reprompt = Reprompt;
|
||||
existingCipher.Key = Key;
|
||||
existingCipher.ArchivedDate = ArchivedDate;
|
||||
existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId);
|
||||
existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite);
|
||||
existingCipher.Archives = UpdateUserSpecificJsonField(existingCipher.Archives, userIdKey, ArchivedDate);
|
||||
|
||||
var hasAttachments2 = (Attachments2?.Count ?? 0) > 0;
|
||||
var hasAttachments = (Attachments?.Count ?? 0) > 0;
|
||||
|
||||
@@ -70,7 +70,6 @@ public class CipherMiniResponseModel : ResponseModel
|
||||
DeletedDate = cipher.DeletedDate;
|
||||
Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None);
|
||||
Key = cipher.Key;
|
||||
ArchivedDate = cipher.ArchivedDate;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@@ -111,7 +110,6 @@ public class CipherMiniResponseModel : ResponseModel
|
||||
public DateTime? DeletedDate { get; set; }
|
||||
public CipherRepromptType Reprompt { get; set; }
|
||||
public string Key { get; set; }
|
||||
public DateTime? ArchivedDate { get; set; }
|
||||
}
|
||||
|
||||
public class CipherResponseModel : CipherMiniResponseModel
|
||||
@@ -127,6 +125,7 @@ public class CipherResponseModel : CipherMiniResponseModel
|
||||
FolderId = cipher.FolderId;
|
||||
Favorite = cipher.Favorite;
|
||||
Edit = cipher.Edit;
|
||||
ArchivedDate = cipher.ArchivedDate;
|
||||
ViewPassword = cipher.ViewPassword;
|
||||
Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbilities);
|
||||
}
|
||||
@@ -135,6 +134,7 @@ public class CipherResponseModel : CipherMiniResponseModel
|
||||
public bool Favorite { get; set; }
|
||||
public bool Edit { get; set; }
|
||||
public bool ViewPassword { get; set; }
|
||||
public DateTime? ArchivedDate { get; set; }
|
||||
public CipherPermissionsResponseModel Permissions { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
c => new CollectionDetailsResponseModel(c)) ?? new List<CollectionDetailsResponseModel>();
|
||||
Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
|
||||
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
|
||||
Sends = sends.Select(s => new SendResponseModel(s, globalSettings));
|
||||
Sends = sends.Select(s => new SendResponseModel(s));
|
||||
UserDecryption = new UserDecryptionResponseModel
|
||||
{
|
||||
MasterPasswordUnlock = user.HasMasterPassword()
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Billing</UserSecretsId>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Label="Server SDK settings">
|
||||
<!-- These features will be gradually turned on -->
|
||||
<BitIncludeFeatures>false</BitIncludeFeatures>
|
||||
<BitIncludeTelemetry>false</BitIncludeTelemetry>
|
||||
<BitIncludeAuthentication>false</BitIncludeAuthentication>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bit.Billing.Services;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Jobs;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
@@ -13,12 +14,23 @@ namespace Bit.Billing.Jobs;
|
||||
public class ReconcileAdditionalStorageJob(
|
||||
IStripeFacade stripeFacade,
|
||||
ILogger<ReconcileAdditionalStorageJob> logger,
|
||||
IFeatureService featureService) : BaseJob(logger)
|
||||
IFeatureService featureService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IStripeEventUtilityService stripeEventUtilityService) : BaseJob(logger)
|
||||
{
|
||||
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
|
||||
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
|
||||
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
|
||||
private const int _storageGbToRemove = 4;
|
||||
private const short _includedStorageGb = 5;
|
||||
|
||||
public enum SubscriptionPlanTier
|
||||
{
|
||||
Personal,
|
||||
Organization,
|
||||
Unknown
|
||||
}
|
||||
|
||||
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
|
||||
{
|
||||
@@ -34,6 +46,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
var subscriptionsFound = 0;
|
||||
var subscriptionsUpdated = 0;
|
||||
var subscriptionsWithErrors = 0;
|
||||
var databaseUpdatesFailed = 0;
|
||||
var failures = new List<string>();
|
||||
|
||||
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
|
||||
@@ -51,11 +64,13 @@ public class ReconcileAdditionalStorageJob(
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
"Stripe updates: {StripeUpdates}, Database updates: {DatabaseFailed} failed, " +
|
||||
"Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
subscriptionsFound,
|
||||
liveMode
|
||||
? subscriptionsUpdated
|
||||
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||
databaseUpdatesFailed,
|
||||
subscriptionsWithErrors,
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
@@ -99,20 +114,68 @@ public class ReconcileAdditionalStorageJob(
|
||||
|
||||
subscriptionsUpdated++;
|
||||
|
||||
if (!liveMode)
|
||||
// Now, prepare the database update so we can log details out if not in live mode
|
||||
var (organizationId, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata ?? new Dictionary<string, string>());
|
||||
var subscriptionPlanTier = DetermineSubscriptionPlanTier(userId, organizationId);
|
||||
|
||||
if (subscriptionPlanTier == SubscriptionPlanTier.Unknown)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
|
||||
subscription.Id,
|
||||
Environment.NewLine,
|
||||
JsonSerializer.Serialize(updateOptions));
|
||||
logger.LogError(
|
||||
"Cannot determine subscription plan tier for {SubscriptionId}. Skipping subscription. ",
|
||||
subscription.Id);
|
||||
subscriptionsWithErrors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var entityId =
|
||||
subscriptionPlanTier switch
|
||||
{
|
||||
SubscriptionPlanTier.Personal => userId!.Value,
|
||||
SubscriptionPlanTier.Organization => organizationId!.Value,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(subscriptionPlanTier), subscriptionPlanTier, null)
|
||||
};
|
||||
|
||||
// Calculate new MaxStorageGb
|
||||
var currentStorageQuantity = GetCurrentStorageQuantityFromSubscription(subscription, priceId);
|
||||
var newMaxStorageGb = CalculateNewMaxStorageGb(currentStorageQuantity, updateOptions);
|
||||
|
||||
if (!liveMode)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}" +
|
||||
"{NewLine2}And would have updated database record tier: {Tier} to new MaxStorageGb: {MaxStorageGb}",
|
||||
subscription.Id,
|
||||
Environment.NewLine,
|
||||
JsonSerializer.Serialize(updateOptions),
|
||||
Environment.NewLine,
|
||||
subscriptionPlanTier,
|
||||
newMaxStorageGb);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Live mode enabled - continue with updates to stripe and database
|
||||
try
|
||||
{
|
||||
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
|
||||
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
|
||||
logger.LogInformation("Successfully updated Stripe subscription: {SubscriptionId}", subscription.Id);
|
||||
|
||||
logger.LogInformation(
|
||||
"Updating MaxStorageGb in database for subscription {SubscriptionId} ({Type}): New MaxStorageGb: {MaxStorage}",
|
||||
subscription.Id,
|
||||
subscriptionPlanTier,
|
||||
newMaxStorageGb);
|
||||
|
||||
var dbUpdateSuccess = await UpdateDatabaseMaxStorageAsync(
|
||||
subscriptionPlanTier,
|
||||
entityId,
|
||||
newMaxStorageGb,
|
||||
subscription.Id);
|
||||
|
||||
if (!dbUpdateSuccess)
|
||||
{
|
||||
databaseUpdatesFailed++;
|
||||
failures.Add($"Subscription {subscription.Id}: Database update failed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -125,12 +188,14 @@ public class ReconcileAdditionalStorageJob(
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
"ReconcileAdditionalStorageJob FINISHED. Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Subscriptions updated: {SubscriptionsUpdated}, Database failures: {DatabaseFailed}, " +
|
||||
"Total Subscriptions With Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
subscriptionsFound,
|
||||
liveMode
|
||||
? subscriptionsUpdated
|
||||
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||
databaseUpdatesFailed,
|
||||
subscriptionsWithErrors,
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
@@ -182,6 +247,117 @@ public class ReconcileAdditionalStorageJob(
|
||||
return hasUpdates ? updateOptions : null;
|
||||
}
|
||||
|
||||
public SubscriptionPlanTier DetermineSubscriptionPlanTier(
|
||||
Guid? userId,
|
||||
Guid? organizationId)
|
||||
{
|
||||
return userId.HasValue
|
||||
? SubscriptionPlanTier.Personal
|
||||
: organizationId.HasValue
|
||||
? SubscriptionPlanTier.Organization
|
||||
: SubscriptionPlanTier.Unknown;
|
||||
}
|
||||
|
||||
public long GetCurrentStorageQuantityFromSubscription(
|
||||
Subscription subscription,
|
||||
string storagePriceId)
|
||||
{
|
||||
return subscription.Items?.Data?.FirstOrDefault(item => item?.Price?.Id == storagePriceId)?.Quantity ?? 0;
|
||||
}
|
||||
|
||||
public short CalculateNewMaxStorageGb(
|
||||
long currentQuantity,
|
||||
SubscriptionUpdateOptions? updateOptions)
|
||||
{
|
||||
if (updateOptions?.Items == null)
|
||||
{
|
||||
return (short)(_includedStorageGb + currentQuantity);
|
||||
}
|
||||
|
||||
// If the update marks item as deleted, new quantity is whatever the base storage gb
|
||||
if (updateOptions.Items.Any(i => i.Deleted == true))
|
||||
{
|
||||
return _includedStorageGb;
|
||||
}
|
||||
|
||||
// If the update has a new quantity, use it to calculate the new max
|
||||
var updatedItem = updateOptions.Items.FirstOrDefault(i => i.Quantity.HasValue);
|
||||
if (updatedItem?.Quantity != null)
|
||||
{
|
||||
return (short)(_includedStorageGb + updatedItem.Quantity.Value);
|
||||
}
|
||||
|
||||
// Otherwise, no change
|
||||
return (short)(_includedStorageGb + currentQuantity);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateDatabaseMaxStorageAsync(
|
||||
SubscriptionPlanTier subscriptionPlanTier,
|
||||
Guid entityId,
|
||||
short newMaxStorageGb,
|
||||
string subscriptionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (subscriptionPlanTier)
|
||||
{
|
||||
case SubscriptionPlanTier.Personal:
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(entityId);
|
||||
if (user == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"User not found for subscription {SubscriptionId}. Database not updated.",
|
||||
subscriptionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
user.MaxStorageGb = newMaxStorageGb;
|
||||
await userRepository.ReplaceAsync(user);
|
||||
|
||||
logger.LogInformation(
|
||||
"Successfully updated User {UserId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
|
||||
user.Id,
|
||||
newMaxStorageGb,
|
||||
subscriptionId);
|
||||
return true;
|
||||
}
|
||||
case SubscriptionPlanTier.Organization:
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(entityId);
|
||||
if (organization == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Organization not found for subscription {SubscriptionId}. Database not updated.",
|
||||
subscriptionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
organization.MaxStorageGb = newMaxStorageGb;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
logger.LogInformation(
|
||||
"Successfully updated Organization {OrganizationId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
|
||||
organization.Id,
|
||||
newMaxStorageGb,
|
||||
subscriptionId);
|
||||
return true;
|
||||
}
|
||||
case SubscriptionPlanTier.Unknown:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Failed to update database MaxStorageGb for subscription {SubscriptionId} (Plan Tier: {SubscriptionType})",
|
||||
subscriptionId,
|
||||
subscriptionPlanTier);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static ITrigger GetTrigger()
|
||||
{
|
||||
return TriggerBuilder.Create()
|
||||
|
||||
@@ -43,7 +43,7 @@ public class PayPalIPNTransactionModel
|
||||
var merchantGross = Extract(data, "mc_gross");
|
||||
if (!string.IsNullOrEmpty(merchantGross))
|
||||
{
|
||||
MerchantGross = decimal.Parse(merchantGross);
|
||||
MerchantGross = decimal.Parse(merchantGross, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
MerchantCurrency = Extract(data, "mc_currency");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Services;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class InvoiceCreatedHandler(
|
||||
IBraintreeService braintreeService,
|
||||
ILogger<InvoiceCreatedHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
: IInvoiceCreatedHandler
|
||||
{
|
||||
@@ -29,9 +30,9 @@ public class InvoiceCreatedHandler(
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]);
|
||||
|
||||
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
|
||||
var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId");
|
||||
|
||||
if (usingPayPal && invoice is
|
||||
{
|
||||
@@ -39,13 +40,12 @@ public class InvoiceCreatedHandler(
|
||||
Status: not StripeConstants.InvoiceStatus.Paid,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason:
|
||||
"subscription_create" or
|
||||
"subscription_cycle" or
|
||||
"automatic_pending_invoice_item_invoice",
|
||||
Parent.SubscriptionDetails: not null
|
||||
Parent.SubscriptionDetails.Subscription: not null
|
||||
})
|
||||
{
|
||||
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -48,6 +48,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// PayPal IPN Client
|
||||
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();
|
||||
|
||||
@@ -134,6 +134,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
||||
/// </summary>
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set to true, disables Secrets Manager ads for users in the organization
|
||||
/// </summary>
|
||||
public bool UseDisableSmAdsForUsers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set to true, the organization has phishing protection enabled.
|
||||
/// </summary>
|
||||
@@ -338,6 +343,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
||||
UseRiskInsights = license.UseRiskInsights;
|
||||
UseOrganizationDomains = license.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
||||
UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers;
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
||||
UsePhishingBlocker = license.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record DatadogIntegration(string ApiKey, Uri Uri);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegration(string Token);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string ChannelId);
|
||||
@@ -53,5 +53,7 @@ public interface IProfileOrganizationDetails
|
||||
bool UseAdminSponsoredFamilies { get; set; }
|
||||
bool UseOrganizationDomains { get; set; }
|
||||
bool UseAutomaticUserConfirmation { get; set; }
|
||||
bool UseDisableSMAdsForUsers { get; set; }
|
||||
|
||||
bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -65,5 +65,6 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool? IsAdminInitiated { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSMAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
UseApi = UseApi,
|
||||
UseResetPassword = UseResetPassword,
|
||||
UseSecretsManager = UseSecretsManager,
|
||||
UsePasswordManager = UsePasswordManager,
|
||||
SelfHost = SelfHost,
|
||||
UsersGetPremium = UsersGetPremium,
|
||||
UseCustomPermissions = UseCustomPermissions,
|
||||
@@ -154,7 +155,10 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
Status = Status,
|
||||
UseRiskInsights = UseRiskInsights,
|
||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
|
||||
UsePhishingBlocker = UsePhishingBlocker,
|
||||
UseOrganizationDomains = UseOrganizationDomains,
|
||||
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,5 +56,6 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
|
||||
public string? SsoExternalId { get; set; }
|
||||
public string? Permissions { get; set; }
|
||||
public string? ResetPasswordKey { get; set; }
|
||||
public bool UseDisableSMAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
|
||||
public abstract class OrganizationConfirmationBaseView : BaseMailView
|
||||
{
|
||||
public required string OrganizationName { get; set; }
|
||||
public required string TitleFirst { get; set; }
|
||||
public required string TitleSecondBold { get; set; }
|
||||
public required string TitleThird { get; set; }
|
||||
public required string WebVaultUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
|
||||
public class OrganizationConfirmationEnterpriseTeamsView : OrganizationConfirmationBaseView
|
||||
{
|
||||
}
|
||||
|
||||
public class OrganizationConfirmationEnterpriseTeams : BaseMail<OrganizationConfirmationEnterpriseTeamsView>
|
||||
{
|
||||
public override required string Subject { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
<!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]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
|
||||
</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-15 { width:15% !important; max-width: 15%; }
|
||||
.mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
}
|
||||
</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-15 { width:15% !important; max-width: 15%; }
|
||||
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
</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; }
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- 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>
|
||||
|
||||
</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">
|
||||
You can now share passwords with members of <b>{{OrganizationName}}!</b>
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<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:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="{{WebVaultUrl}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
<b>Log in</b>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</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-enterprise.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:15px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a member of <b>{{OrganizationName}}</b>:</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<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: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:48px;">
|
||||
|
||||
<img alt="Organization Icon" src="https://assets.bitwarden.com/email/v1/icon-enterprise.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">Your account is owned by {{OrganizationName}} and is subject to their security and management policies.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</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:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<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: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:48px;">
|
||||
|
||||
<img alt="Group Users Icon" src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can easily access and share passwords with your team.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/sharing" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
|
||||
Share passwords in Bitwarden
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
|
||||
</span>
|
||||
</a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding: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>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
|
||||
public class OrganizationConfirmationFamilyFreeView : OrganizationConfirmationBaseView
|
||||
{
|
||||
}
|
||||
|
||||
public class OrganizationConfirmationFamilyFree : BaseMail<OrganizationConfirmationFamilyFreeView>
|
||||
{
|
||||
public override required string Subject { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
<!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]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
|
||||
</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-15 { width:15% !important; max-width: 15%; }
|
||||
.mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
.mj-column-px-120 { width:120px !important; max-width: 120px; }
|
||||
.mj-column-px-150 { width:150px !important; max-width: 150px; }
|
||||
}
|
||||
</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-15 { width:15% !important; max-width: 15%; }
|
||||
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
.moz-text-html .mj-column-px-120 { width:120px !important; max-width: 120px; }
|
||||
.moz-text-html .mj-column-px-150 { width:150px !important; max-width: 150px; }
|
||||
</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; }
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- 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>
|
||||
|
||||
</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">
|
||||
You can now share passwords with members of <b>{{OrganizationName}}!</b>
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<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:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="{{WebVaultUrl}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
<b>Log in</b>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</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-family-homes.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:15px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a member of <b>{{OrganizationName}}</b>:</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<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: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:48px;">
|
||||
|
||||
<img alt="Collections Icon" src="https://assets.bitwarden.com/email/v1/icon-item-type.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can access passwords {{OrganizationName}} has shared with you.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</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:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<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: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:48px;">
|
||||
|
||||
<img alt="Group Users Icon" src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can easily share passwords with friends, family, or coworkers.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/sharing" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
|
||||
Share passwords in Bitwarden
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
|
||||
</span>
|
||||
</a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Download Mobile Apps 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="#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:30px 30px 10px 30px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:0 0 15px 0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:18px;font-weight:700;line-height:32px;text-align:left;color:#1B2029;">Download Bitwarden on all devices</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:0 0 20px 0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:15px;line-height:16px;text-align:left;color:#1B2029;">Already using the <a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">browser extension</a>?
|
||||
Download the Bitwarden mobile app from the
|
||||
<a href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">App Store</a>
|
||||
or <a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Google Play</a>
|
||||
to quickly save logins and autofill forms on the go.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 30px 20px 30px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:560px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:middle;width:120px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-px-120 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:21.428571428571427%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;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:120px;">
|
||||
|
||||
<a href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744" target="_blank">
|
||||
|
||||
<img alt="Download on the App Store" src="https://assets.bitwarden.com/email/v1/App-store.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="120" height="auto">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:middle;width:150px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-px-150 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:26.785714285714285%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0 0 0 10px;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:140px;">
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden" target="_blank">
|
||||
|
||||
<img alt="Get it on Google Play" src="https://assets.bitwarden.com/email/v1/google-play-badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="140" height="auto">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</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>
|
||||
|
||||
@@ -29,6 +29,7 @@ public class OrganizationAbility
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
@@ -52,5 +53,6 @@ public class OrganizationAbility
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSmAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
# Organization Ability Flags
|
||||
|
||||
## Overview
|
||||
|
||||
Many Bitwarden features are tied to specific subscription plans. For example, SCIM and SSO are Enterprise features,
|
||||
while Event Logs are available to Teams and Enterprise plans. When developing features that require plan-based access
|
||||
control, we use **Organization Ability Flags** (or simply _abilities_) — explicit boolean properties on the Organization
|
||||
entity that indicate whether an organization can use a specific feature.
|
||||
|
||||
## The Rule
|
||||
|
||||
**Never check plan types to control feature access.** Always use a dedicated ability flag on the Organization entity.
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```csharp
|
||||
// Checking plan type directly
|
||||
if (organization.PlanType == PlanType.Enterprise ||
|
||||
organization.PlanType == PlanType.Teams ||
|
||||
organization.PlanType == PlanType.Family)
|
||||
{
|
||||
// allow feature...
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```csharp
|
||||
// Piggybacking off another feature's ability
|
||||
if (organization.PlanType == PlanType.Enterprise && organization.UseEvents)
|
||||
{
|
||||
// assume they can use some other feature...
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Do This Instead
|
||||
|
||||
```csharp
|
||||
// Check the explicit ability flag
|
||||
if (organization.UseEvents)
|
||||
{
|
||||
// allow UseEvents feature...
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Pattern Matters
|
||||
|
||||
Using explicit ability flags instead of plan type checks provides several benefits:
|
||||
|
||||
1. **Simplicity** — A single boolean check is cleaner and less error-prone than maintaining lists of plan types.
|
||||
|
||||
2. **Centralized Control** — Feature access is managed in one place: the ability assignment during organization
|
||||
creation/upgrade. No need to hunt through the codebase for scattered plan type checks.
|
||||
|
||||
3. **Flexibility** — Abilities can be set independently of plan type, enabling:
|
||||
|
||||
- Early access programs for features not yet tied to a plan
|
||||
- Trial access to help customers evaluate a feature before upgrading
|
||||
- Custom arrangements for specific customers
|
||||
- A/B testing of features across different cohorts
|
||||
|
||||
4. **Safe Refactoring** — When plans change (e.g., adding a new plan tier, renaming plans, or moving features between
|
||||
tiers), we only update the ability assignment logic—not every place the feature is used.
|
||||
|
||||
5. **Graceful Downgrades** — When an organization downgrades, we update their abilities. All feature checks
|
||||
automatically respect the new access level.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Ability Assignment at Signup/Upgrade
|
||||
|
||||
When an organization is created or changes plans, the ability flags are set based on the plan's capabilities:
|
||||
|
||||
```csharp
|
||||
// During organization creation or plan change
|
||||
organization.UseGroups = plan.HasGroups;
|
||||
organization.UseSso = plan.HasSso;
|
||||
organization.UseScim = plan.HasScim;
|
||||
organization.UsePolicies = plan.HasPolicies;
|
||||
organization.UseEvents = plan.HasEvents;
|
||||
// ... etc
|
||||
```
|
||||
|
||||
### Modifying Abilities for Existing Organizations
|
||||
|
||||
To change abilities for existing organizations (e.g., rolling out a feature to a new plan tier), create a database
|
||||
migration that updates the relevant flag:
|
||||
|
||||
```sql
|
||||
-- Example: Enable UseEvents for all Teams organizations
|
||||
UPDATE [dbo].[Organization]
|
||||
SET UseEvents = 1
|
||||
WHERE PlanType IN (17, 18) -- TeamsMonthly = 17, TeamsAnnually = 18
|
||||
```
|
||||
|
||||
Then update the plan-to-ability assignment code so new organizations get the correct value.
|
||||
|
||||
## Adding a New Ability
|
||||
|
||||
When developing a new plan-gated feature:
|
||||
|
||||
1. **Add the ability to the Organization and OrganizationAbility entities** — Create a `Use[FeatureName]` boolean
|
||||
property.
|
||||
|
||||
2. **Add a database migration** — Add the new column to the Organization table.
|
||||
|
||||
3. **Update plan definitions** — Add a corresponding `Has[FeatureName]` property to the Plan model and configure which
|
||||
plans include it.
|
||||
|
||||
4. **Update organization creation/upgrade logic** — Ensure the ability is set based on the plan.
|
||||
|
||||
5. **Update the organization license claims** (if applicable) - to make the feature available on self-hosted instances.
|
||||
|
||||
6. **Implement checks throughout client and server** — Use the ability consistently everywhere the feature is accessed.
|
||||
- Clients: get the organization object from `OrganizationService`.
|
||||
- Server: if you already have the full `Organization` object in scope, you can use it directly. If not, use the
|
||||
`IApplicationCacheService` to retrieve the `OrganizationAbility`, which is a simplified, cached representation
|
||||
of the organization ability flags. Note that some older flags may be missing from `OrganizationAbility` but
|
||||
can be added if needed.
|
||||
|
||||
## Existing Abilities
|
||||
|
||||
For reference, here are some current organization ability flags (not a complete list):
|
||||
|
||||
| Ability | Description | Plans |
|
||||
|--------------------------|-------------------------------|-------------------|
|
||||
| `UseGroups` | Group-based collection access | Teams, Enterprise |
|
||||
| `UseDirectory` | Directory Connector sync | Teams, Enterprise |
|
||||
| `UseEvents` | Event logging | Teams, Enterprise |
|
||||
| `UseTotp` | Authenticator (TOTP) | Teams, Enterprise |
|
||||
| `UseSso` | Single Sign-On | Enterprise |
|
||||
| `UseScim` | SCIM provisioning | Teams, Enterprise |
|
||||
| `UsePolicies` | Enterprise policies | Enterprise |
|
||||
| `UseResetPassword` | Admin password reset | Enterprise |
|
||||
| `UseOrganizationDomains` | Domain verification/claiming | Enterprise |
|
||||
|
||||
## Questions?
|
||||
|
||||
If you're unsure whether your feature needs a new ability or which existing ability to use, reach out to your team lead
|
||||
or members of the Admin Console or Architecture teams. When in doubt, adding an explicit ability is almost always the
|
||||
right choice—it's easy to do and keeps our access control clean and maintainable.
|
||||
@@ -1,5 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.Entities;
|
||||
@@ -25,6 +27,8 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
IPushNotificationService pushNotificationService,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
ICollectionRepository collectionRepository,
|
||||
IFeatureService featureService,
|
||||
ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand
|
||||
{
|
||||
@@ -143,9 +147,7 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
|
||||
|
||||
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
|
||||
user!.Email,
|
||||
request.OrganizationUser.AccessSecretsManager);
|
||||
await SendOrganizationConfirmedEmailAsync(request.Organization!, user!.Email, request.OrganizationUser.AccessSecretsManager);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -183,4 +185,23 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
Organization = await organizationRepository.GetByIdAsync(request.OrganizationId)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service,
|
||||
/// depending on the feature flag.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization the user was confirmed to.</param>
|
||||
/// <param name="userEmail">The email address of the confirmed user.</param>
|
||||
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
|
||||
internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager)
|
||||
{
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail))
|
||||
{
|
||||
await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager);
|
||||
}
|
||||
else
|
||||
{
|
||||
await mailService.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user