mirror of
https://github.com/bitwarden/server
synced 2026-01-29 15:53:36 +00:00
Merge branch 'main' into ac/pm-28842/cap-password-minimum-length
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
|
||||
|
||||
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
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,19 @@ 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}"
|
||||
EndProject
|
||||
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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -350,10 +359,26 @@ 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
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -410,7 +435,11 @@ 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}
|
||||
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,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)
|
||||
{
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class AccountsController(
|
||||
IFeatureService featureService,
|
||||
ILicensingService licensingService) : Controller
|
||||
{
|
||||
// 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,
|
||||
@@ -61,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)
|
||||
@@ -118,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()
|
||||
@@ -131,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ 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;
|
||||
@@ -21,11 +23,14 @@ namespace Bit.Api.Billing.Controllers.VNext;
|
||||
public class AccountBillingVNextController(
|
||||
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
|
||||
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
|
||||
IGetCreditQuery getCreditQuery,
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IGetUserLicenseQuery getUserLicenseQuery,
|
||||
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
|
||||
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
|
||||
{
|
||||
[HttpGet("credit")]
|
||||
[InjectUser]
|
||||
@@ -90,14 +95,45 @@ public class AccountBillingVNextController(
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("storage")]
|
||||
[HttpGet("subscription")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpdateStorageAsync(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject
|
||||
/// Must be between 0 and the maximum allowed (minus base storage).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(0, 99)]
|
||||
public short AdditionalStorageGb { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
@@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Additional storage cannot be negative.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
|
||||
if (AdditionalStorageGb > 99)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Maximum additional storage is 99 GB.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -5,9 +5,11 @@ 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;
|
||||
@@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Bit.Api.Tools.Controllers;
|
||||
|
||||
[Route("sends")]
|
||||
[Authorize("Application")]
|
||||
public class SendsController : Controller
|
||||
{
|
||||
private readonly ISendRepository _sendRepository;
|
||||
@@ -31,11 +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,
|
||||
@@ -46,7 +46,8 @@ public class SendsController : Controller
|
||||
ISendOwnerQuery sendOwnerQuery,
|
||||
ISendFileStorageService sendFileStorageService,
|
||||
ILogger<SendsController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
IFeatureService featureService,
|
||||
IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_sendRepository = sendRepository;
|
||||
_userService = userService;
|
||||
@@ -56,10 +57,12 @@ public class SendsController : Controller
|
||||
_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)
|
||||
@@ -73,21 +76,32 @@ public class SendsController : Controller
|
||||
|
||||
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
|
||||
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();
|
||||
@@ -99,6 +113,7 @@ public class SendsController : Controller
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
|
||||
@@ -122,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);
|
||||
|
||||
@@ -129,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]
|
||||
@@ -157,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)
|
||||
@@ -166,6 +187,7 @@ public class SendsController : Controller
|
||||
{
|
||||
await azureSendFileStorageService.DeleteBlobAsync(blobName);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,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;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +208,7 @@ public class SendsController : Controller
|
||||
|
||||
#region Non-anonymous endpoints
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}")]
|
||||
public async Task<SendResponseModel> Get(string id)
|
||||
{
|
||||
@@ -193,6 +217,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<SendResponseModel>> GetAll()
|
||||
{
|
||||
@@ -203,6 +228,67 @@ public class SendsController : Controller
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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 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)
|
||||
{
|
||||
@@ -213,6 +299,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("file/v2")]
|
||||
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -243,6 +330,7 @@ public class SendsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}/file/{fileId}")]
|
||||
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
|
||||
{
|
||||
@@ -267,6 +355,7 @@ public class SendsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("{id}/file/{fileId}")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
[RequestSizeLimit(Constants.FileSize501mb)]
|
||||
@@ -283,12 +372,14 @@ public class SendsController : Controller
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -304,6 +395,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}/remove-password")]
|
||||
public async Task<SendResponseModel> PutRemovePassword(string id)
|
||||
{
|
||||
@@ -322,6 +414,28 @@ public class SendsController : Controller
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -903,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;
|
||||
|
||||
@@ -914,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)
|
||||
{
|
||||
@@ -927,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);
|
||||
|
||||
@@ -937,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}")]
|
||||
@@ -1101,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;
|
||||
|
||||
@@ -1112,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)
|
||||
{
|
||||
@@ -1125,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);
|
||||
|
||||
@@ -1135,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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ public class SendOrganizationConfirmationCommand(IMailer mailer, GlobalSettings
|
||||
private const string _titleThird = "!";
|
||||
|
||||
private static string GetConfirmationSubject(string organizationName) =>
|
||||
$"You Have Been Confirmed To {organizationName}";
|
||||
$"You can now access items from {organizationName}";
|
||||
private string GetWebVaultUrl(bool accessSecretsManager) => accessSecretsManager
|
||||
? globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct
|
||||
: globalSettings.BaseServiceUri.VaultWithHash;
|
||||
|
||||
@@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand
|
||||
{
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId);
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||
}
|
||||
|
||||
@@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand(
|
||||
await organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId)
|
||||
{
|
||||
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
|
||||
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
|
||||
.ToList();
|
||||
|
||||
if (!filteredUsers.Any())
|
||||
{
|
||||
throw new BadRequestException("Users invalid.");
|
||||
}
|
||||
|
||||
if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
|
||||
var deletingUserIsOwner = false;
|
||||
if (revokingUserId.HasValue)
|
||||
{
|
||||
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
|
||||
}
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
|
||||
foreach (var organizationUser in filteredUsers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (organizationUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
throw new BadRequestException("Already revoked.");
|
||||
}
|
||||
|
||||
if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId)
|
||||
{
|
||||
throw new BadRequestException("You cannot revoke yourself.");
|
||||
}
|
||||
|
||||
if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue &&
|
||||
!deletingUserIsOwner)
|
||||
{
|
||||
throw new BadRequestException("Only owners can revoke other owners.");
|
||||
}
|
||||
|
||||
await organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
|
||||
result.Add(Tuple.Create(organizationUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(organizationUser, e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
|
||||
ICollection<OrganizationUserUserDetails> organizationUsers)
|
||||
{
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||
var userIds = organizationUsers.Where(
|
||||
u => u.UserId is not null &&
|
||||
u.Status != OrganizationUserStatusType.Invited)
|
||||
.Select(u => u.UserId!.Value);
|
||||
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
|
||||
.Any(uo => uo.OrganizationId != organizationId
|
||||
&& uo.Status != OrganizationUserStatusType.Invited);
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ public static class OrganizationFactory
|
||||
UseAdminSponsoredFamilies =
|
||||
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies),
|
||||
UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation),
|
||||
UseDisableSmAdsForUsers =
|
||||
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers),
|
||||
UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker),
|
||||
};
|
||||
|
||||
@@ -113,6 +115,7 @@ public static class OrganizationFactory
|
||||
UseOrganizationDomains = license.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation,
|
||||
UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers,
|
||||
UsePhishingBlocker = license.UsePhishingBlocker,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject<Guid>
|
||||
public string KeyEncrypted { get; set; }
|
||||
public EmergencyAccessType Type { get; set; }
|
||||
public EmergencyAccessStatusType Status { get; set; }
|
||||
public int WaitTimeDays { get; set; }
|
||||
public short WaitTimeDays { get; set; }
|
||||
public DateTime? RecoveryInitiatedDate { get; set; }
|
||||
public DateTime? LastNotificationDate { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Data model for setting an initial master password for a user.
|
||||
/// </summary>
|
||||
public class SetInitialMasterPasswordDataModel
|
||||
{
|
||||
public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; }
|
||||
public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Organization SSO identifier.
|
||||
/// </summary>
|
||||
public required string OrgSsoIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User account keys. Required for Master Password decryption user.
|
||||
/// </summary>
|
||||
public required UserAccountKeysData? AccountKeys { get; set; }
|
||||
public string? MasterPasswordHint { get; set; }
|
||||
}
|
||||
@@ -79,7 +79,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
Email = emergencyContactEmail.ToLowerInvariant(),
|
||||
Status = EmergencyAccessStatusType.Invited,
|
||||
Type = accessType,
|
||||
WaitTimeDays = waitTime,
|
||||
WaitTimeDays = (short)waitTime,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// <para>Manages the setting of the initial master password for a <see cref="User"/> in an organization.</para>
|
||||
/// <para>This class is primarily invoked in two scenarios:</para>
|
||||
/// <para>1) In organizations configured with Single Sign-On (SSO) and master password decryption:
|
||||
/// <para>In organizations configured with Single Sign-On (SSO) and master password decryption:
|
||||
/// just in time (JIT) provisioned users logging in via SSO are required to set a master password.</para>
|
||||
/// <para>2) In organizations configured with SSO and trusted devices decryption:
|
||||
/// Users who are upgraded to have admin account recovery permissions must set a master password
|
||||
/// to ensure their ability to reset other users' accounts.</para>
|
||||
/// </summary>
|
||||
public interface ISetInitialMasterPasswordCommand
|
||||
{
|
||||
public Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
|
||||
string orgSsoIdentifier);
|
||||
/// <summary>
|
||||
/// Sets the initial master password and account keys for the specified user.
|
||||
/// </summary>
|
||||
/// <param name="user">User to set the master password for</param>
|
||||
/// <param name="masterPasswordDataModel">Initial master password setup data</param>
|
||||
/// <returns>A task that completes when the operation succeeds</returns>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown if the user's master password is already set, the organization is not found,
|
||||
/// the user is not a member of the organization, or the account keys are missing.
|
||||
/// </exception>
|
||||
public Task SetInitialMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// <para>Manages the setting of the initial master password for a <see cref="User"/> in an organization.</para>
|
||||
/// <para>This class is primarily invoked in two scenarios:</para>
|
||||
/// <para>1) In organizations configured with Single Sign-On (SSO) and master password decryption:
|
||||
/// just in time (JIT) provisioned users logging in via SSO are required to set a master password.</para>
|
||||
/// <para>2) In organizations configured with SSO and trusted devices decryption:
|
||||
/// Users who are upgraded to have admin account recovery permissions must set a master password
|
||||
/// to ensure their ability to reset other users' accounts.</para>
|
||||
/// </summary>
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
|
||||
[Obsolete("Use ISetInitialMasterPasswordCommand instead")]
|
||||
public interface ISetInitialMasterPasswordCommandV1
|
||||
{
|
||||
public Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
|
||||
string orgSsoIdentifier);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// <para>Manages the setting of the master password for a TDE <see cref="User"/> in an organization.</para>
|
||||
/// <para>In organizations configured with SSO and trusted devices decryption:
|
||||
/// Users who are upgraded to have admin account recovery permissions must set a master password
|
||||
/// to ensure their ability to reset other users' accounts.</para>
|
||||
/// </summary>
|
||||
public interface ITdeSetPasswordCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the master password for the specified TDE user.
|
||||
/// </summary>
|
||||
/// <param name="user">User to set the master password for</param>
|
||||
/// <param name="masterPasswordDataModel">Master password setup data</param>
|
||||
/// <returns>A task that completes when the operation succeeds</returns>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown if the user's master password is already set, the organization is not found,
|
||||
/// the user is not a member of the organization, or the user is a TDE user without account keys set.
|
||||
/// </exception>
|
||||
Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -6,98 +7,74 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword;
|
||||
|
||||
public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand
|
||||
{
|
||||
private readonly ILogger<SetInitialMasterPasswordCommand> _logger;
|
||||
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IPasswordHasher<User> _passwordHasher;
|
||||
private readonly IEventService _eventService;
|
||||
|
||||
|
||||
public SetInitialMasterPasswordCommand(
|
||||
ILogger<SetInitialMasterPasswordCommand> logger,
|
||||
IdentityErrorDescriber identityErrorDescriber,
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
IEventService eventService,
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository)
|
||||
public SetInitialMasterPasswordCommand(IUserService userService, IUserRepository userRepository,
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand, IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository, IPasswordHasher<User> passwordHasher,
|
||||
IEventService eventService)
|
||||
{
|
||||
_logger = logger;
|
||||
_identityErrorDescriber = identityErrorDescriber;
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_eventService = eventService;
|
||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
_eventService = eventService;
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
|
||||
string orgSsoIdentifier)
|
||||
public async Task SetInitialMasterPasswordAsync(User user,
|
||||
SetInitialMasterPasswordDataModel masterPasswordDataModel)
|
||||
{
|
||||
if (user == null)
|
||||
if (user.Key != null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
throw new BadRequestException("User already has a master password set.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.MasterPassword))
|
||||
if (masterPasswordDataModel.AccountKeys == null)
|
||||
{
|
||||
_logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id);
|
||||
return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword());
|
||||
throw new BadRequestException("Account keys are required.");
|
||||
}
|
||||
|
||||
var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
|
||||
user.Key = key;
|
||||
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
|
||||
|
||||
|
||||
if (string.IsNullOrWhiteSpace(orgSsoIdentifier))
|
||||
{
|
||||
throw new BadRequestException("Organization SSO Identifier required.");
|
||||
}
|
||||
|
||||
var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier);
|
||||
// Prevent a de-synced salt value from creating an un-decryptable unlock method
|
||||
masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user);
|
||||
masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user);
|
||||
|
||||
var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier);
|
||||
if (org == null)
|
||||
{
|
||||
throw new BadRequestException("Organization invalid.");
|
||||
throw new BadRequestException("Organization SSO identifier is invalid.");
|
||||
}
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
|
||||
|
||||
if (orgUser == null)
|
||||
{
|
||||
throw new BadRequestException("User not found within organization.");
|
||||
}
|
||||
|
||||
// TDE users who go from a user without admin acct recovery permission to having it will be
|
||||
// required to set a MP for the first time and we don't want to re-execute the accept logic
|
||||
// as they are already confirmed.
|
||||
// TLDR: only accept post SSO user if they are invited
|
||||
if (orgUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService);
|
||||
}
|
||||
// Hash the provided user master password authentication hash on the server side
|
||||
var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user,
|
||||
masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash);
|
||||
|
||||
return IdentityResult.Success;
|
||||
var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id,
|
||||
masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash,
|
||||
masterPasswordDataModel.MasterPasswordHint);
|
||||
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, masterPasswordDataModel.AccountKeys,
|
||||
[setMasterPasswordTask]);
|
||||
|
||||
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
|
||||
|
||||
await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword;
|
||||
|
||||
public class SetInitialMasterPasswordCommandV1 : ISetInitialMasterPasswordCommandV1
|
||||
{
|
||||
private readonly ILogger<SetInitialMasterPasswordCommandV1> _logger;
|
||||
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
|
||||
|
||||
public SetInitialMasterPasswordCommandV1(
|
||||
ILogger<SetInitialMasterPasswordCommandV1> logger,
|
||||
IdentityErrorDescriber identityErrorDescriber,
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
IEventService eventService,
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository)
|
||||
{
|
||||
_logger = logger;
|
||||
_identityErrorDescriber = identityErrorDescriber;
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_eventService = eventService;
|
||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
|
||||
string orgSsoIdentifier)
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.MasterPassword))
|
||||
{
|
||||
_logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id);
|
||||
return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword());
|
||||
}
|
||||
|
||||
var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
|
||||
user.Key = key;
|
||||
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
|
||||
|
||||
|
||||
if (string.IsNullOrWhiteSpace(orgSsoIdentifier))
|
||||
{
|
||||
throw new BadRequestException("Organization SSO Identifier required.");
|
||||
}
|
||||
|
||||
var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier);
|
||||
|
||||
if (org == null)
|
||||
{
|
||||
throw new BadRequestException("Organization invalid.");
|
||||
}
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
|
||||
|
||||
if (orgUser == null)
|
||||
{
|
||||
throw new BadRequestException("User not found within organization.");
|
||||
}
|
||||
|
||||
// TDE users who go from a user without admin acct recovery permission to having it will be
|
||||
// required to set a MP for the first time and we don't want to re-execute the accept logic
|
||||
// as they are already confirmed.
|
||||
// TLDR: only accept post SSO user if they are invited
|
||||
if (orgUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService);
|
||||
}
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword;
|
||||
|
||||
public class TdeSetPasswordCommand : ITdeSetPasswordCommand
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IPasswordHasher<User> _passwordHasher;
|
||||
private readonly IEventService _eventService;
|
||||
|
||||
public TdeSetPasswordCommand(IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository,
|
||||
IPasswordHasher<User> passwordHasher, IEventService eventService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
_eventService = eventService;
|
||||
}
|
||||
|
||||
public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel)
|
||||
{
|
||||
if (user.Key != null)
|
||||
{
|
||||
throw new BadRequestException("User already has a master password set.");
|
||||
}
|
||||
|
||||
if (user.PublicKey == null || user.PrivateKey == null)
|
||||
{
|
||||
throw new BadRequestException("TDE user account keys must be set before setting initial master password.");
|
||||
}
|
||||
|
||||
// Prevent a de-synced salt value from creating an un-decryptable unlock method
|
||||
masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user);
|
||||
masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user);
|
||||
|
||||
var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier);
|
||||
if (org == null)
|
||||
{
|
||||
throw new BadRequestException("Organization SSO identifier is invalid.");
|
||||
}
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id);
|
||||
if (orgUser == null)
|
||||
{
|
||||
throw new BadRequestException("User not found within organization.");
|
||||
}
|
||||
|
||||
// Hash the provided user master password authentication hash on the server side
|
||||
var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user,
|
||||
masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash);
|
||||
|
||||
var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id,
|
||||
masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash,
|
||||
masterPasswordDataModel.MasterPasswordHint);
|
||||
await _userRepository.UpdateUserDataAsync([setMasterPasswordTask]);
|
||||
|
||||
await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ public static class UserServiceCollectionExtensions
|
||||
private static void AddUserPasswordCommands(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ISetInitialMasterPasswordCommand, SetInitialMasterPasswordCommand>();
|
||||
services.AddScoped<ISetInitialMasterPasswordCommandV1, SetInitialMasterPasswordCommandV1>();
|
||||
services.AddScoped<ITdeSetPasswordCommand, TdeSetPasswordCommand>();
|
||||
}
|
||||
|
||||
private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services)
|
||||
|
||||
@@ -42,6 +42,7 @@ public static class StripeConstants
|
||||
public static class ErrorCodes
|
||||
{
|
||||
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";
|
||||
public const string InvoiceUpcomingNone = "invoice_upcoming_none";
|
||||
public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded";
|
||||
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
|
||||
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
|
||||
@@ -65,8 +66,14 @@ public static class StripeConstants
|
||||
public static class MetadataKeys
|
||||
{
|
||||
public const string BraintreeCustomerId = "btCustomerId";
|
||||
public const string BraintreeTransactionId = "btTransactionId";
|
||||
public const string InvoiceApproved = "invoice_approved";
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string PayPalTransactionId = "btPayPalTransactionId";
|
||||
public const string PreviousAdditionalStorage = "previous_additional_storage";
|
||||
public const string PreviousPeriodEndDate = "previous_period_end_date";
|
||||
public const string PreviousPremiumPriceId = "previous_premium_price_id";
|
||||
public const string PreviousPremiumUserId = "previous_premium_user_id";
|
||||
public const string ProviderId = "providerId";
|
||||
public const string Region = "region";
|
||||
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
namespace Bit.Core.Billing.Enums;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace Bit.Core.Billing.Enums;
|
||||
|
||||
public enum PlanCadenceType
|
||||
{
|
||||
[EnumMember(Value = "annually")]
|
||||
Annually,
|
||||
[EnumMember(Value = "monthly")]
|
||||
Monthly
|
||||
}
|
||||
|
||||
12
src/Core/Billing/Extensions/DiscountExtensions.cs
Normal file
12
src/Core/Billing/Extensions/DiscountExtensions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
public static class DiscountExtensions
|
||||
{
|
||||
public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem)
|
||||
=> discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id);
|
||||
|
||||
public static bool IsValid(this Discount? discount)
|
||||
=> discount?.Coupon?.Valid ?? false;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
@@ -51,7 +52,7 @@ public static class InvoiceExtensions
|
||||
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
|
||||
{
|
||||
var pricePerItem = (line.Amount / 100m) / line.Quantity;
|
||||
priceInfo = $"(at ${pricePerItem:F2} / month)";
|
||||
priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem);
|
||||
}
|
||||
|
||||
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
|
||||
@@ -70,7 +71,7 @@ public static class InvoiceExtensions
|
||||
if (tax > 0)
|
||||
{
|
||||
var taxAmount = tax / 100m;
|
||||
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
|
||||
items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount));
|
||||
}
|
||||
|
||||
return items;
|
||||
|
||||
@@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Billing.Tax.Services.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Services.Implementations;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
@@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
|
||||
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
|
||||
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
|
||||
services.AddTransient<IGetBitwardenSubscriptionQuery, GetBitwardenSubscriptionQuery>();
|
||||
services.AddTransient<IReinstateSubscriptionCommand, ReinstateSubscriptionCommand>();
|
||||
services.AddTransient<IBraintreeService, BraintreeService>();
|
||||
}
|
||||
|
||||
private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)
|
||||
@@ -54,6 +60,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
|
||||
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
|
||||
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
|
||||
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
|
||||
}
|
||||
|
||||
private static void AddPremiumQueries(this IServiceCollection services)
|
||||
|
||||
@@ -44,6 +44,7 @@ public static class OrganizationLicenseConstants
|
||||
public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies);
|
||||
public const string UseOrganizationDomains = nameof(UseOrganizationDomains);
|
||||
public const string UseAutomaticUserConfirmation = nameof(UseAutomaticUserConfirmation);
|
||||
public const string UseDisableSmAdsForUsers = nameof(UseDisableSmAdsForUsers);
|
||||
public const string UsePhishingBlocker = nameof(UsePhishingBlocker);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
||||
new(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UseOrganizationDomains), entity.UseOrganizationDomains.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UseAutomaticUserConfirmation), entity.UseAutomaticUserConfirmation.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UseDisableSmAdsForUsers), entity.UseDisableSmAdsForUsers.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UsePhishingBlocker), entity.UsePhishingBlocker.ToString()),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
@@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
}
|
||||
|
||||
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
|
||||
|
||||
// If the license has a Token (claims-based), extract all properties from claims BEFORE validation
|
||||
// This ensures that CanUseLicense validation has access to the correct values from claims
|
||||
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
license.Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name);
|
||||
license.BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail);
|
||||
license.BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName);
|
||||
license.PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
|
||||
license.Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats);
|
||||
license.MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections);
|
||||
license.UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies);
|
||||
license.UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso);
|
||||
license.UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector);
|
||||
license.UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim);
|
||||
license.UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups);
|
||||
license.UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory);
|
||||
license.UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents);
|
||||
license.UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp);
|
||||
license.Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa);
|
||||
license.UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi);
|
||||
license.UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword);
|
||||
license.Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan);
|
||||
license.SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost);
|
||||
license.UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium);
|
||||
license.UseCustomPermissions = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions);
|
||||
license.Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled);
|
||||
license.Expires = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires);
|
||||
license.LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey);
|
||||
license.UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager);
|
||||
license.UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager);
|
||||
license.SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats);
|
||||
license.SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts);
|
||||
license.UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights);
|
||||
license.UseOrganizationDomains = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains);
|
||||
license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies);
|
||||
license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
|
||||
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
|
||||
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
|
||||
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
|
||||
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
|
||||
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
|
||||
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
|
||||
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
|
||||
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
|
||||
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
|
||||
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
|
||||
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
|
||||
}
|
||||
|
||||
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
|
||||
selfHostedOrganization.CanUseLicense(license, out exception);
|
||||
|
||||
@@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
throw new BadRequestException(exception);
|
||||
}
|
||||
|
||||
var useAutomaticUserConfirmation = claimsPrincipal?
|
||||
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
|
||||
|
||||
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
|
||||
await WriteLicenseFileAsync(selfHostedOrganization, license);
|
||||
await UpdateOrganizationAsync(selfHostedOrganization, license);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ public class OrganizationLicense : ILicense
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSmAdsForUsers { get; set; }
|
||||
public string Hash { get; set; }
|
||||
public string Signature { get; set; }
|
||||
public string Token { get; set; }
|
||||
@@ -230,6 +231,7 @@ public class OrganizationLicense : ILicense
|
||||
!p.Name.Equals(nameof(UseAdminSponsoredFamilies)) &&
|
||||
!p.Name.Equals(nameof(UseOrganizationDomains)) &&
|
||||
!p.Name.Equals(nameof(UseAutomaticUserConfirmation)) &&
|
||||
!p.Name.Equals(nameof(UseDisableSmAdsForUsers)) &&
|
||||
!p.Name.Equals(nameof(UsePhishingBlocker)))
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}")
|
||||
@@ -425,6 +427,7 @@ public class OrganizationLicense : ILicense
|
||||
var useAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(nameof(UseAdminSponsoredFamilies));
|
||||
var useOrganizationDomains = claimsPrincipal.GetValue<bool>(nameof(UseOrganizationDomains));
|
||||
var useAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(nameof(UseAutomaticUserConfirmation));
|
||||
var useDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(nameof(UseDisableSmAdsForUsers));
|
||||
|
||||
var claimedPlanType = claimsPrincipal.GetValue<PlanType>(nameof(PlanType));
|
||||
|
||||
@@ -461,7 +464,8 @@ public class OrganizationLicense : ILicense
|
||||
smServiceAccounts == organization.SmServiceAccounts &&
|
||||
useAdminSponsoredFamilies == organization.UseAdminSponsoredFamilies &&
|
||||
useOrganizationDomains == organization.UseOrganizationDomains &&
|
||||
useAutomaticUserConfirmation == organization.UseAutomaticUserConfirmation;
|
||||
useAutomaticUserConfirmation == organization.UseAutomaticUserConfirmation &&
|
||||
useDisableSmAdsForUsers == organization.UseDisableSmAdsForUsers;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Platform.Push;
|
||||
@@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
|
||||
|
||||
public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IBraintreeService braintreeService,
|
||||
IGlobalSettings globalSettings,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
@@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
ValidateLocation = ValidateTaxLocationTiming.Immediately
|
||||
}
|
||||
};
|
||||
|
||||
return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
|
||||
}
|
||||
|
||||
@@ -351,14 +354,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
|
||||
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
|
||||
|
||||
if (usingPayPal)
|
||||
if (!usingPayPal)
|
||||
{
|
||||
await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false,
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
await braintreeService.PayInvoice(new UserId(userId), invoice);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
@@ -10,6 +11,8 @@ using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Premium.Commands;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the storage allocation for a premium user's subscription.
|
||||
/// Handles both increases and decreases in storage in an idempotent manner.
|
||||
@@ -34,14 +37,14 @@ public class UpdatePremiumStorageCommand(
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
|
||||
{
|
||||
if (!user.Premium)
|
||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||
{
|
||||
return new BadRequest("User does not have a premium subscription.");
|
||||
}
|
||||
|
||||
if (!user.MaxStorageGb.HasValue)
|
||||
{
|
||||
return new BadRequest("No access to storage.");
|
||||
return new BadRequest("User has no access to storage.");
|
||||
}
|
||||
|
||||
// Fetch all premium plans and the user's subscription to find which plan they're on
|
||||
@@ -54,7 +57,7 @@ public class UpdatePremiumStorageCommand(
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
return new BadRequest("Premium subscription item not found.");
|
||||
return new Conflict("Premium subscription does not have a Password Manager line item.");
|
||||
}
|
||||
|
||||
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||
@@ -66,20 +69,20 @@ public class UpdatePremiumStorageCommand(
|
||||
return new BadRequest("Additional storage cannot be negative.");
|
||||
}
|
||||
|
||||
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
|
||||
var maxStorageGb = (short)(baseStorageGb + additionalStorageGb);
|
||||
|
||||
if (newTotalStorageGb > 100)
|
||||
if (maxStorageGb > 100)
|
||||
{
|
||||
return new BadRequest("Maximum storage is 100 GB.");
|
||||
}
|
||||
|
||||
// Idempotency check: if user already has the requested storage, return success
|
||||
if (user.MaxStorageGb == newTotalStorageGb)
|
||||
if (user.MaxStorageGb == maxStorageGb)
|
||||
{
|
||||
return new None();
|
||||
}
|
||||
|
||||
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
|
||||
var remainingStorage = user.StorageBytesRemaining(maxStorageGb);
|
||||
if (remainingStorage < 0)
|
||||
{
|
||||
return new BadRequest(
|
||||
@@ -124,21 +127,18 @@ public class UpdatePremiumStorageCommand(
|
||||
});
|
||||
}
|
||||
|
||||
// Update subscription with prorations
|
||||
// Storage is billed annually, so we create prorations and invoice immediately
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = Core.Constants.CreateProrations
|
||||
ProrationBehavior = ProrationBehavior.AlwaysInvoice
|
||||
};
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
|
||||
|
||||
// Update the user's max storage
|
||||
user.MaxStorageGb = newTotalStorageGb;
|
||||
user.MaxStorageGb = maxStorageGb;
|
||||
await userService.SaveUserAsync(user);
|
||||
|
||||
// No payment intent needed - the subscription update will automatically create and finalize the invoice
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Premium.Commands;
|
||||
/// <summary>
|
||||
/// Upgrades a user's Premium subscription to an Organization plan by creating a new Organization
|
||||
/// and transferring the subscription from the User to the Organization.
|
||||
/// </summary>
|
||||
public interface IUpgradePremiumToOrganizationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Upgrades a Premium subscription to an Organization subscription.
|
||||
/// </summary>
|
||||
/// <param name="user">The user with an active Premium subscription to upgrade.</param>
|
||||
/// <param name="organizationName">The name for the new organization.</param>
|
||||
/// <param name="key">The encrypted organization key for the owner.</param>
|
||||
/// <param name="targetPlanType">The target organization plan type to upgrade to.</param>
|
||||
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
||||
Task<BillingCommandResult<None>> Run(
|
||||
User user,
|
||||
string organizationName,
|
||||
string key,
|
||||
PlanType targetPlanType);
|
||||
}
|
||||
|
||||
public class UpgradePremiumToOrganizationCommand(
|
||||
ILogger<UpgradePremiumToOrganizationCommand> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
: BaseBillingCommand<UpgradePremiumToOrganizationCommand>(logger), IUpgradePremiumToOrganizationCommand
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(
|
||||
User user,
|
||||
string organizationName,
|
||||
string key,
|
||||
PlanType targetPlanType) => HandleAsync<None>(async () =>
|
||||
{
|
||||
// Validate that the user has an active Premium subscription
|
||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||
{
|
||||
return new BadRequest("User does not have an active Premium subscription.");
|
||||
}
|
||||
|
||||
// Hardcode seats to 1 for upgrade flow
|
||||
const int seats = 1;
|
||||
|
||||
// Fetch the current Premium subscription from Stripe
|
||||
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
|
||||
|
||||
// Fetch all premium plans to find which specific plan the user is on
|
||||
var premiumPlans = await pricingClient.ListPremiumPlans();
|
||||
|
||||
// Find the password manager subscription item (seat, not storage) and match it to a plan
|
||||
var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
return new BadRequest("Premium subscription item not found.");
|
||||
}
|
||||
|
||||
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||
|
||||
// Get the target organization plan
|
||||
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
|
||||
|
||||
// Build the list of subscription item updates
|
||||
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
|
||||
|
||||
// Delete the user's specific password manager item
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
|
||||
// Delete the storage item if it exists for this user's plan
|
||||
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
||||
|
||||
// Capture the previous additional storage quantity for potential revert
|
||||
var previousAdditionalStorage = storageItem?.Quantity ?? 0;
|
||||
|
||||
if (storageItem != null)
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = storageItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
}
|
||||
|
||||
// Add new organization subscription items
|
||||
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = targetPlan.PasswordManager.StripePlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = seats
|
||||
});
|
||||
}
|
||||
|
||||
// Generate organization ID early to include in metadata
|
||||
var organizationId = CoreHelpers.GenerateComb();
|
||||
|
||||
// Build the subscription update options
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.None,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId,
|
||||
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty,
|
||||
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(),
|
||||
[StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User
|
||||
}
|
||||
};
|
||||
|
||||
// Create the Organization entity
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Name = organizationName,
|
||||
BillingEmail = user.Email,
|
||||
PlanType = targetPlan.Type,
|
||||
Seats = (short)seats,
|
||||
MaxCollections = targetPlan.PasswordManager.MaxCollections,
|
||||
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
|
||||
UsePolicies = targetPlan.HasPolicies,
|
||||
UseSso = targetPlan.HasSso,
|
||||
UseGroups = targetPlan.HasGroups,
|
||||
UseEvents = targetPlan.HasEvents,
|
||||
UseDirectory = targetPlan.HasDirectory,
|
||||
UseTotp = targetPlan.HasTotp,
|
||||
Use2fa = targetPlan.Has2fa,
|
||||
UseApi = targetPlan.HasApi,
|
||||
UseResetPassword = targetPlan.HasResetPassword,
|
||||
SelfHost = targetPlan.HasSelfHost,
|
||||
UsersGetPremium = targetPlan.UsersGetPremium,
|
||||
UseCustomPermissions = targetPlan.HasCustomPermissions,
|
||||
UseScim = targetPlan.HasScim,
|
||||
Plan = targetPlan.Name,
|
||||
Gateway = GatewayType.Stripe,
|
||||
Enabled = true,
|
||||
LicenseKey = CoreHelpers.SecureRandomString(20),
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
Status = OrganizationStatusType.Created,
|
||||
UsePasswordManager = true,
|
||||
UseSecretsManager = false,
|
||||
UseOrganizationDomains = targetPlan.HasOrganizationDomains,
|
||||
GatewayCustomerId = user.GatewayCustomerId,
|
||||
GatewaySubscriptionId = currentSubscription.Id
|
||||
};
|
||||
|
||||
// Update the subscription in Stripe
|
||||
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);
|
||||
|
||||
// Save the organization
|
||||
await organizationRepository.CreateAsync(organization);
|
||||
|
||||
// Create organization API key
|
||||
await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30),
|
||||
Type = OrganizationApiKeyType.Default,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
// Update cache
|
||||
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
|
||||
// Create OrganizationUser for the upgrading user as owner
|
||||
var organizationUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Key = key,
|
||||
AccessSecretsManager = false,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
CreationDate = organization.CreationDate,
|
||||
RevisionDate = organization.CreationDate
|
||||
};
|
||||
organizationUser.SetNewId();
|
||||
await organizationUserRepository.CreateAsync(organizationUser);
|
||||
|
||||
// Remove subscription from user
|
||||
user.Premium = false;
|
||||
user.PremiumExpirationDate = null;
|
||||
user.GatewaySubscriptionId = null;
|
||||
user.GatewayCustomerId = null;
|
||||
user.RevisionDate = DateTime.UtcNow;
|
||||
await userService.SaveUserAsync(user);
|
||||
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Commands;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public interface IReinstateSubscriptionCommand
|
||||
{
|
||||
Task<BillingCommandResult<None>> Run(ISubscriber subscriber);
|
||||
}
|
||||
|
||||
public class ReinstateSubscriptionCommand(
|
||||
ILogger<ReinstateSubscriptionCommand> logger,
|
||||
IStripeAdapter stripeAdapter) : BaseBillingCommand<ReinstateSubscriptionCommand>(logger), IReinstateSubscriptionCommand
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsync<None>(async () =>
|
||||
{
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
|
||||
CancelAt: not null
|
||||
})
|
||||
{
|
||||
return new BadRequest("Subscription is not pending cancellation.");
|
||||
}
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
|
||||
{
|
||||
CancelAtPeriodEnd = false
|
||||
});
|
||||
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
61
src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs
Normal file
61
src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The type of discounts Bitwarden supports.
|
||||
/// </summary>
|
||||
public enum BitwardenDiscountType
|
||||
{
|
||||
[EnumMember(Value = "amount-off")]
|
||||
AmountOff,
|
||||
|
||||
[EnumMember(Value = "percent-off")]
|
||||
PercentOff
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A record representing a discount applied to a Bitwarden subscription.
|
||||
/// </summary>
|
||||
public record BitwardenDiscount
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of the discount.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<BitwardenDiscountType>))]
|
||||
public required BitwardenDiscountType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value of the discount.
|
||||
/// </summary>
|
||||
public required decimal Value { get; init; }
|
||||
|
||||
public static implicit operator BitwardenDiscount(Discount? discount)
|
||||
{
|
||||
if (discount is not
|
||||
{
|
||||
Coupon.Valid: true
|
||||
})
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
return discount.Coupon switch
|
||||
{
|
||||
{ AmountOff: > 0 } => new BitwardenDiscount
|
||||
{
|
||||
Type = BitwardenDiscountType.AmountOff,
|
||||
Value = discount.Coupon.AmountOff.Value
|
||||
},
|
||||
{ PercentOff: > 0 } => new BitwardenDiscount
|
||||
{
|
||||
Type = BitwardenDiscountType.PercentOff,
|
||||
Value = discount.Coupon.PercentOff.Value
|
||||
},
|
||||
_ => null!
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record BitwardenSubscription
|
||||
{
|
||||
/// <summary>
|
||||
/// The status of the subscription.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription's cart, including line items, any discounts, and estimated tax.
|
||||
/// </summary>
|
||||
public required Cart Cart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage available and used for the subscription.
|
||||
/// <remarks>Allowed Subscribers: User, Organization</remarks>
|
||||
/// </summary>
|
||||
public Storage? Storage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If the subscription is pending cancellation, the date at which the
|
||||
/// subscription will be canceled.
|
||||
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? CancelAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date the subscription was canceled.
|
||||
/// <remarks>Allowed Statuses: 'canceled'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? Canceled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date of the next charge for the subscription.
|
||||
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? NextCharge { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date the subscription will be or was suspended due to lack of payment.
|
||||
/// <remarks>Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? Suspension { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of days after the subscription goes 'past_due' the subscriber has to resolve their
|
||||
/// open invoices before the subscription is suspended.
|
||||
/// <remarks>Allowed Statuses: 'past_due'</remarks>
|
||||
/// </summary>
|
||||
public int? GracePeriod { get; init; }
|
||||
}
|
||||
83
src/Core/Billing/Subscriptions/Models/Cart.cs
Normal file
83
src/Core/Billing/Subscriptions/Models/Cart.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record CartItem
|
||||
{
|
||||
/// <summary>
|
||||
/// The client-side translation key for the name of the cart item.
|
||||
/// </summary>
|
||||
public required string TranslationKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The quantity of the cart item.
|
||||
/// </summary>
|
||||
public required long Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The unit-cost of the cart item.
|
||||
/// </summary>
|
||||
public required decimal Cost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional discount applied specifically to this cart item.
|
||||
/// </summary>
|
||||
public BitwardenDiscount? Discount { get; init; }
|
||||
}
|
||||
|
||||
public record PasswordManagerCartItems
|
||||
{
|
||||
/// <summary>
|
||||
/// The Password Manager seats in the cart.
|
||||
/// </summary>
|
||||
public required CartItem Seats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The additional storage in the cart.
|
||||
/// </summary>
|
||||
public CartItem? AdditionalStorage { get; init; }
|
||||
}
|
||||
|
||||
public record SecretsManagerCartItems
|
||||
{
|
||||
/// <summary>
|
||||
/// The Secrets Manager seats in the cart.
|
||||
/// </summary>
|
||||
public required CartItem Seats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The additional service accounts in the cart.
|
||||
/// </summary>
|
||||
public CartItem? AdditionalServiceAccounts { get; init; }
|
||||
}
|
||||
|
||||
public record Cart
|
||||
{
|
||||
/// <summary>
|
||||
/// The Password Manager items in the cart.
|
||||
/// </summary>
|
||||
public required PasswordManagerCartItems PasswordManager { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Secrets Manager items in the cart.
|
||||
/// </summary>
|
||||
public SecretsManagerCartItems? SecretsManager { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The cart's billing cadence.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<PlanCadenceType>))]
|
||||
public PlanCadenceType Cadence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional discount applied to the entire cart.
|
||||
/// </summary>
|
||||
public BitwardenDiscount? Discount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The estimated tax for the cart.
|
||||
/// </summary>
|
||||
public required decimal EstimatedTax { get; init; }
|
||||
}
|
||||
52
src/Core/Billing/Subscriptions/Models/Storage.cs
Normal file
52
src/Core/Billing/Subscriptions/Models/Storage.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using OneOf;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record Storage
|
||||
{
|
||||
private const double _bytesPerGibibyte = 1073741824D;
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has available.
|
||||
/// </summary>
|
||||
public required short Available { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has used.
|
||||
/// </summary>
|
||||
public required double Used { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has used, formatted as a human-readable string.
|
||||
/// </summary>
|
||||
public required string ReadableUsed { get; init; }
|
||||
|
||||
public static implicit operator Storage(User user) => From(user);
|
||||
public static implicit operator Storage(Organization organization) => From(organization);
|
||||
|
||||
private static Storage From(OneOf<User, Organization> subscriber)
|
||||
{
|
||||
var maxStorageGB = subscriber.Match(
|
||||
user => user.MaxStorageGb,
|
||||
organization => organization.MaxStorageGb);
|
||||
|
||||
if (maxStorageGB == null)
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
var storage = subscriber.Match(
|
||||
user => user.Storage,
|
||||
organization => organization.Storage);
|
||||
|
||||
return new Storage
|
||||
{
|
||||
Available = maxStorageGB.Value,
|
||||
Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2),
|
||||
ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0)
|
||||
};
|
||||
}
|
||||
}
|
||||
43
src/Core/Billing/Subscriptions/Models/SubscriberId.cs
Normal file
43
src/Core/Billing/Subscriptions/Models/SubscriberId.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Exceptions;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public record UserId(Guid Value);
|
||||
|
||||
public record OrganizationId(Guid Value);
|
||||
|
||||
public record ProviderId(Guid Value);
|
||||
|
||||
public class SubscriberId : OneOfBase<UserId, OrganizationId, ProviderId>
|
||||
{
|
||||
private SubscriberId(OneOf<UserId, OrganizationId, ProviderId> input) : base(input) { }
|
||||
|
||||
public static implicit operator SubscriberId(UserId value) => new(value);
|
||||
public static implicit operator SubscriberId(OrganizationId value) => new(value);
|
||||
public static implicit operator SubscriberId(ProviderId value) => new(value);
|
||||
|
||||
public static implicit operator SubscriberId(Subscription subscription)
|
||||
{
|
||||
if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue)
|
||||
&& Guid.TryParse(userIdValue, out var userId))
|
||||
{
|
||||
return new UserId(userId);
|
||||
}
|
||||
|
||||
if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue)
|
||||
&& Guid.TryParse(organizationIdValue, out var organizationId))
|
||||
{
|
||||
return new OrganizationId(organizationId);
|
||||
}
|
||||
|
||||
return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) &&
|
||||
Guid.TryParse(providerIdValue, out var providerId)
|
||||
? new ProviderId(providerId)
|
||||
: throw new ConflictException("Subscription does not have a valid subscriber ID");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Queries;
|
||||
|
||||
using static StripeConstants;
|
||||
using static Utilities;
|
||||
|
||||
public interface IGetBitwardenSubscriptionQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves detailed subscription information for a user, including subscription status,
|
||||
/// cart items, discounts, and billing details.
|
||||
/// </summary>
|
||||
/// <param name="user">The user whose subscription information to retrieve.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="BitwardenSubscription"/> containing the subscription details, or null if no
|
||||
/// subscription is found or the subscription status is not recognized.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
|
||||
/// <see cref="ISubscriber"/> types (User and Organization).
|
||||
/// </remarks>
|
||||
Task<BitwardenSubscription> Run(User user);
|
||||
}
|
||||
|
||||
public class GetBitwardenSubscriptionQuery(
|
||||
ILogger<GetBitwardenSubscriptionQuery> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
|
||||
{
|
||||
public async Task<BitwardenSubscription> Run(User user)
|
||||
{
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand =
|
||||
[
|
||||
"customer.discount.coupon.applies_to",
|
||||
"discounts.coupon.applies_to",
|
||||
"items.data.price.product",
|
||||
"test_clock"
|
||||
]
|
||||
});
|
||||
|
||||
var cart = await GetPremiumCartAsync(subscription);
|
||||
|
||||
var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user };
|
||||
|
||||
switch (subscription.Status)
|
||||
{
|
||||
case SubscriptionStatus.Incomplete:
|
||||
case SubscriptionStatus.IncompleteExpired:
|
||||
return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 };
|
||||
|
||||
case SubscriptionStatus.Trialing:
|
||||
case SubscriptionStatus.Active:
|
||||
return baseSubscription with
|
||||
{
|
||||
NextCharge = subscription.GetCurrentPeriodEnd(),
|
||||
CancelAt = subscription.CancelAt
|
||||
};
|
||||
|
||||
case SubscriptionStatus.PastDue:
|
||||
case SubscriptionStatus.Unpaid:
|
||||
var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
|
||||
if (suspension == null)
|
||||
{
|
||||
return baseSubscription;
|
||||
}
|
||||
return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod };
|
||||
|
||||
case SubscriptionStatus.Canceled:
|
||||
return baseSubscription with { Canceled = subscription.CanceledAt };
|
||||
|
||||
default:
|
||||
{
|
||||
logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status);
|
||||
throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Cart> GetPremiumCartAsync(
|
||||
Subscription subscription)
|
||||
{
|
||||
var plans = await pricingClient.ListPremiumPlans();
|
||||
|
||||
var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item =>
|
||||
plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id));
|
||||
|
||||
if (passwordManagerSeatsItem == null)
|
||||
{
|
||||
throw new ConflictException("Premium subscription does not have a Password Manager line item.");
|
||||
}
|
||||
|
||||
var additionalStorageItem = subscription.Items.FirstOrDefault(item =>
|
||||
plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id));
|
||||
|
||||
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
|
||||
|
||||
var passwordManagerSeats = new CartItem
|
||||
{
|
||||
TranslationKey = "premiumMembership",
|
||||
Quantity = passwordManagerSeatsItem.Quantity,
|
||||
Cost = GetCost(passwordManagerSeatsItem),
|
||||
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem))
|
||||
};
|
||||
|
||||
var additionalStorage = additionalStorageItem != null
|
||||
? new CartItem
|
||||
{
|
||||
TranslationKey = "additionalStorageGB",
|
||||
Quantity = additionalStorageItem.Quantity,
|
||||
Cost = GetCost(additionalStorageItem),
|
||||
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem))
|
||||
}
|
||||
: null;
|
||||
|
||||
var estimatedTax = await EstimateTaxAsync(subscription);
|
||||
|
||||
return new Cart
|
||||
{
|
||||
PasswordManager = new PasswordManagerCartItems
|
||||
{
|
||||
Seats = passwordManagerSeats,
|
||||
AdditionalStorage = additionalStorage
|
||||
},
|
||||
Cadence = PlanCadenceType.Annually,
|
||||
Discount = cartLevelDiscount,
|
||||
EstimatedTax = estimatedTax
|
||||
};
|
||||
}
|
||||
|
||||
#region Utilities
|
||||
|
||||
private async Task<decimal> EstimateTaxAsync(Subscription subscription)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions
|
||||
{
|
||||
Customer = subscription.Customer.Id,
|
||||
Subscription = subscription.Id
|
||||
});
|
||||
|
||||
return GetCost(invoice.TotalTaxes);
|
||||
}
|
||||
catch (StripeException stripeException) when
|
||||
(stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal GetCost(OneOf<SubscriptionItem, List<InvoiceTotalTax>> value) =>
|
||||
value.Match(
|
||||
item => (item.Price.UnitAmountDecimal ?? 0) / 100M,
|
||||
taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M);
|
||||
|
||||
private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDiscounts(
|
||||
Subscription subscription)
|
||||
{
|
||||
var discounts = new List<Discount>();
|
||||
|
||||
if (subscription.Customer.Discount.IsValid())
|
||||
{
|
||||
discounts.Add(subscription.Customer.Discount);
|
||||
}
|
||||
|
||||
discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid()));
|
||||
|
||||
var cartLevel = new List<Discount>();
|
||||
var productLevel = new List<Discount>();
|
||||
|
||||
foreach (var discount in discounts)
|
||||
{
|
||||
switch (discount)
|
||||
{
|
||||
case { Coupon.AppliesTo.Products: null or { Count: 0 } }:
|
||||
cartLevel.Add(discount);
|
||||
break;
|
||||
case { Coupon.AppliesTo.Products.Count: > 0 }:
|
||||
productLevel.Add(discount);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (cartLevel.FirstOrDefault(), productLevel);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user