logger,
IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
IPricingClient pricingClient,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
@@ -107,6 +108,8 @@ public class OrganizationBillingService(
? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions())
: null;
+ var orgOccupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+
return new OrganizationMetadata(
isEligibleForSelfHost,
isManaged,
@@ -117,7 +120,8 @@ public class OrganizationBillingService(
subscription.Status == StripeConstants.SubscriptionStatus.Canceled,
invoice?.DueDate,
invoice?.Created,
- subscription.CurrentPeriodEnd);
+ subscription.CurrentPeriodEnd,
+ orgOccupiedSeats);
}
public async Task
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 7a2b3c9ac7..3769cafc5c 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -109,6 +109,8 @@ public static class FeatureFlagKeys
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
+ public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript";
+ public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
@@ -144,7 +146,6 @@ public static class FeatureFlagKeys
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
- public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup";
public const string UseOrganizationWarningsService = "use-organization-warnings-service";
@@ -200,7 +201,6 @@ public static class FeatureFlagKeys
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
public const string EndUserNotifications = "pm-10609-end-user-notifications";
- public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string PhishingDetection = "phishing-detection";
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs
index 7ed9fb7d1a..bcf6be62c9 100644
--- a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs
+++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs
@@ -148,7 +148,7 @@
-  |
+  |
 |
 |
 |
diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs
index bf4ec50796..72f669bf34 100644
--- a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs
+++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs
@@ -2,7 +2,7 @@
----------------------------
-- Twitter: https://twitter.com/bitwarden
+- X: https://x.com/bitwarden
- Reddit: https://www.reddit.com/r/Bitwarden/
- Community Forums: https://community.bitwarden.com/
- GitHub: https://github.com/bitwarden
diff --git a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs
index f5772d61f6..f79e5f7043 100644
--- a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs
+++ b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs
@@ -177,7 +177,7 @@
-  |
+  |
 |
 |
 |
diff --git a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.text.hbs b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.text.hbs
index bf4ec50796..72f669bf34 100644
--- a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.text.hbs
+++ b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.text.hbs
@@ -2,7 +2,7 @@
----------------------------
-- Twitter: https://twitter.com/bitwarden
+- X: https://x.com/bitwarden
- Reddit: https://www.reddit.com/r/Bitwarden/
- Community Forums: https://community.bitwarden.com/
- GitHub: https://github.com/bitwarden
diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs
index 76520b4085..f0c97b8589 100644
--- a/src/Core/Services/Implementations/UserService.cs
+++ b/src/Core/Services/Implementations/UserService.cs
@@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
@@ -81,6 +83,7 @@ public class UserService : UserManager, IUserService, IDisposable
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IDistributedCache _distributedCache;
+ private readonly IPolicyRequirementQuery _policyRequirementQuery;
public UserService(
IUserRepository userRepository,
@@ -119,7 +122,8 @@ public class UserService : UserManager, IUserService, IDisposable
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
- IDistributedCache distributedCache)
+ IDistributedCache distributedCache,
+ IPolicyRequirementQuery policyRequirementQuery)
: base(
store,
optionsAccessor,
@@ -164,6 +168,7 @@ public class UserService : UserManager, IUserService, IDisposable
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_distributedCache = distributedCache;
+ _policyRequirementQuery = policyRequirementQuery;
}
public Guid? GetProperUserId(ClaimsPrincipal principal)
@@ -1394,9 +1399,40 @@ public class UserService : UserManager, IUserService, IDisposable
private async Task CheckPoliciesOnTwoFactorRemovalAsync(User user)
{
+ if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
+ {
+ var requirement = await _policyRequirementQuery.GetAsync(user.Id);
+ if (!requirement.OrganizationsRequiringTwoFactor.Any())
+ {
+ Logger.LogInformation("No organizations requiring two factor for user {userId}.", user.Id);
+ return;
+ }
+
+ var organizationIds = requirement.OrganizationsRequiringTwoFactor.Select(o => o.OrganizationId).ToList();
+ var organizations = await _organizationRepository.GetManyByIdsAsync(organizationIds);
+ var organizationLookup = organizations.ToDictionary(org => org.Id);
+
+ var revokeOrgUserTasks = requirement.OrganizationsRequiringTwoFactor
+ .Where(o => organizationLookup.ContainsKey(o.OrganizationId))
+ .Select(async o =>
+ {
+ var organization = organizationLookup[o.OrganizationId];
+ await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync(
+ new RevokeOrganizationUsersRequest(
+ o.OrganizationId,
+ [new OrganizationUserUserDetails { Id = o.OrganizationUserId, OrganizationId = o.OrganizationId }],
+ new SystemUser(EventSystemUser.TwoFactorDisabled)));
+ await _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), user.Email);
+ }).ToArray();
+
+ await Task.WhenAll(revokeOrgUserTasks);
+
+ return;
+ }
+
var twoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication);
- var removeOrgUserTasks = twoFactorPolicies.Select(async p =>
+ var legacyRevokeOrgUserTasks = twoFactorPolicies.Select(async p =>
{
var organization = await _organizationRepository.GetByIdAsync(p.OrganizationId);
await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync(
@@ -1407,7 +1443,7 @@ public class UserService : UserManager, IUserService, IDisposable
await _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), user.Email);
}).ToArray();
- await Task.WhenAll(removeOrgUserTasks);
+ await Task.WhenAll(legacyRevokeOrgUserTasks);
}
public override async Task ConfirmEmailAsync(User user, string token)
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index d3f4253908..b821f214db 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -288,11 +288,15 @@ public class GlobalSettings : IGlobalSettings
public class AzureServiceBusSettings
{
private string _connectionString;
- private string _topicName;
+ private string _eventTopicName;
+ private string _integrationTopicName;
+ public int MaxRetries { get; set; } = 3;
public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription";
- public virtual string SlackSubscriptionName { get; set; } = "events-slack-subscription";
- public virtual string WebhookSubscriptionName { get; set; } = "events-webhook-subscription";
+ public virtual string SlackEventSubscriptionName { get; set; } = "events-slack-subscription";
+ public virtual string SlackIntegrationSubscriptionName { get; set; } = "integration-slack-subscription";
+ public virtual string WebhookEventSubscriptionName { get; set; } = "events-webhook-subscription";
+ public virtual string WebhookIntegrationSubscriptionName { get; set; } = "integration-webhook-subscription";
public string ConnectionString
{
@@ -300,10 +304,16 @@ public class GlobalSettings : IGlobalSettings
set => _connectionString = value.Trim('"');
}
- public string TopicName
+ public string EventTopicName
{
- get => _topicName;
- set => _topicName = value.Trim('"');
+ get => _eventTopicName;
+ set => _eventTopicName = value.Trim('"');
+ }
+
+ public string IntegrationTopicName
+ {
+ get => _integrationTopicName;
+ set => _integrationTopicName = value.Trim('"');
}
}
@@ -436,6 +446,7 @@ public class GlobalSettings : IGlobalSettings
public class IdentityServerSettings
{
+ public string CertificateLocation { get; set; } = "identity.pfx";
public string CertificateThumbprint { get; set; }
public string CertificatePassword { get; set; }
public string RedisConnectionString { get; set; }
diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs
index eebcb00738..eaf1f9fbba 100644
--- a/src/Core/Utilities/CoreHelpers.cs
+++ b/src/Core/Utilities/CoreHelpers.cs
@@ -660,9 +660,9 @@ public static class CoreHelpers
{
if (globalSettings.SelfHosted &&
SettingHasValue(globalSettings.IdentityServer.CertificatePassword)
- && File.Exists("identity.pfx"))
+ && File.Exists(globalSettings.IdentityServer.CertificateLocation))
{
- return GetCertificate("identity.pfx",
+ return GetCertificate(globalSettings.IdentityServer.CertificateLocation,
globalSettings.IdentityServer.CertificatePassword);
}
else if (SettingHasValue(globalSettings.IdentityServer.CertificateThumbprint))
@@ -712,6 +712,7 @@ public static class CoreHelpers
new(Claims.Premium, isPremium ? "true" : "false"),
new(JwtClaimTypes.Email, user.Email),
new(JwtClaimTypes.EmailVerified, user.EmailVerified ? "true" : "false"),
+ // TODO: [https://bitwarden.atlassian.net/browse/PM-22171] Remove this since it is already added from the persisted grant
new(Claims.SecurityStamp, user.SecurityStamp),
};
diff --git a/src/Events/.dockerignore b/src/Events/.dockerignore
deleted file mode 100644
index fc12f25146..0000000000
--- a/src/Events/.dockerignore
+++ /dev/null
@@ -1,4 +0,0 @@
-*
-!obj/build-output/publish/*
-!obj/Docker/empty/
-!entrypoint.sh
diff --git a/src/Events/Dockerfile b/src/Events/Dockerfile
index 6970dfa7bb..58fbbe3df1 100644
--- a/src/Events/Dockerfile
+++ b/src/Events/Dockerfile
@@ -1,21 +1,62 @@
+###############################################
+# Build stage #
+###############################################
+FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+
+# Docker buildx supplies the value for this arg
+ARG TARGETPLATFORM
+
+# Determine proper runtime value for .NET
+# We put the value in a file to be read by later layers.
+RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
+ RID=linux-x64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
+ RID=linux-arm64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
+ RID=linux-arm ; \
+ fi \
+ && echo "RID=$RID" > /tmp/rid.txt
+
+# Copy required project files
+WORKDIR /source
+COPY . ./
+
+# Restore project dependencies and tools
+WORKDIR /source/src/Events
+RUN . /tmp/rid.txt && dotnet restore -r $RID
+
+# Build project
+RUN . /tmp/rid.txt && dotnet publish \
+ -c release \
+ --no-restore \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ -r $RID \
+ -o out
+
+###############################################
+# App stage #
+###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
+ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
+ENV ASPNETCORE_ENVIRONMENT=Production
+ENV ASPNETCORE_URLS=http://+:5000
+ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
+EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
- krb5-user \
&& rm -rf /var/lib/apt/lists/*
-ENV ASPNETCORE_URLS http://+:5000
+# Copy app from the build stage
WORKDIR /app
-EXPOSE 5000
-COPY obj/build-output/publish .
-COPY entrypoint.sh /
+COPY --from=build /source/src/Events/out /app
+COPY ./src/Events/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
-
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
ENTRYPOINT ["/entrypoint.sh"]
diff --git a/src/Events/entrypoint.sh b/src/Events/entrypoint.sh
index f1bd48e1a3..92b19195ea 100644
--- a/src/Events/entrypoint.sh
+++ b/src/Events/entrypoint.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Setup
@@ -19,31 +19,36 @@ then
LGID=65534
fi
-# Create user and group
+if [ "$(id -u)" = "0" ]
+then
+ # Create user and group
-groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
-groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
-useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
-usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
-mkhomedir_helper $USERNAME
+ groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
+ groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
+ useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
+ usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
+ mkhomedir_helper $USERNAME
-# The rest...
+ # The rest...
-chown -R $USERNAME:$GROUPNAME /app
-mkdir -p /etc/bitwarden/core
-mkdir -p /etc/bitwarden/logs
-mkdir -p /etc/bitwarden/ca-certificates
-chown -R $USERNAME:$GROUPNAME /etc/bitwarden
+ chown -R $USERNAME:$GROUPNAME /app
+ mkdir -p /etc/bitwarden/core
+ mkdir -p /etc/bitwarden/logs
+ mkdir -p /etc/bitwarden/ca-certificates
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden
-if [[ $globalSettings__selfHosted == "true" ]]; then
- cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
- && update-ca-certificates
+ if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
+ fi
+
+ gosu_cmd="gosu $USERNAME:$GROUPNAME"
+else
+ gosu_cmd=""
fi
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
- chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
- cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
- gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
+ cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
+ $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
fi
-exec gosu $USERNAME:$GROUPNAME dotnet /app/Events.dll
+exec $gosu_cmd /app/Events
diff --git a/src/EventsProcessor/Dockerfile b/src/EventsProcessor/Dockerfile
index 4344452f65..928af7fb86 100644
--- a/src/EventsProcessor/Dockerfile
+++ b/src/EventsProcessor/Dockerfile
@@ -1,6 +1,50 @@
+###############################################
+# Build stage #
+###############################################
+FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+
+# Docker buildx supplies the value for this arg
+ARG TARGETPLATFORM
+
+# Determine proper runtime value for .NET
+# We put the value in a file to be read by later layers.
+RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
+ RID=linux-x64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
+ RID=linux-arm64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
+ RID=linux-arm ; \
+ fi \
+ && echo "RID=$RID" > /tmp/rid.txt
+
+# Copy required project files
+WORKDIR /source
+COPY . ./
+
+# Restore project dependencies and tools
+WORKDIR /source/src/EventsProcessor
+RUN . /tmp/rid.txt && dotnet restore -r $RID
+
+# Build project
+RUN . /tmp/rid.txt && dotnet publish \
+ -c release \
+ --no-restore \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ -r $RID \
+ -o out
+
+###############################################
+# App stage #
+###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
+ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
+ENV ASPNETCORE_ENVIRONMENT=Production
+ENV ASPNETCORE_URLS=http://+:5000
+ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
+EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -8,13 +52,11 @@ RUN apt-get update \
curl \
&& rm -rf /var/lib/apt/lists/*
-ENV ASPNETCORE_URLS http://+:5000
+# Copy app from the build stage
WORKDIR /app
-EXPOSE 5000
-COPY obj/build-output/publish .
-COPY entrypoint.sh /
+COPY --from=build /source/src/EventsProcessor/out /app
+COPY ./src/EventsProcessor/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
-
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
-CMD ["./../entrypoint.sh"]
+CMD ["/entrypoint.sh"]
diff --git a/src/EventsProcessor/entrypoint.sh b/src/EventsProcessor/entrypoint.sh
index 0ae7b82cb5..e0d2dc0230 100644
--- a/src/EventsProcessor/entrypoint.sh
+++ b/src/EventsProcessor/entrypoint.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Setup
@@ -19,24 +19,26 @@ then
LGID=65534
fi
-# Create user and group
+if [ "$(id -u)" = "0" ]
+then
+ # Create user and group
-groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
-groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
-useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
-usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
-mkhomedir_helper $USERNAME
+ groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
+ groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
+ useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
+ usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
+ mkhomedir_helper $USERNAME
-# The rest...
+ # The rest...
-chown -R $USERNAME:$GROUPNAME /app
-mkdir -p /etc/bitwarden/logs
-#mkdir -p /etc/bitwarden/ca-certificates
-chown -R $USERNAME:$GROUPNAME /etc/bitwarden
+ chown -R $USERNAME:$GROUPNAME /app
+ mkdir -p /etc/bitwarden/logs
+ mkdir -p /etc/bitwarden/ca-certificates
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden
-if [[ $globalSettings__selfHosted == "true" ]]; then
- cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
- && update-ca-certificates
+ gosu_cmd="gosu $USERNAME:$GROUPNAME"
+else
+ gosu_cmd=""
fi
-exec gosu $USERNAME:$GROUPNAME dotnet /app/EventsProcessor.dll
+exec $gosu_cmd /app/EventsProcessor
diff --git a/src/Icons/.dockerignore b/src/Icons/.dockerignore
deleted file mode 100644
index fc12f25146..0000000000
--- a/src/Icons/.dockerignore
+++ /dev/null
@@ -1,4 +0,0 @@
-*
-!obj/build-output/publish/*
-!obj/Docker/empty/
-!entrypoint.sh
diff --git a/src/Icons/Dockerfile b/src/Icons/Dockerfile
index edc1e0905b..16c88e22fa 100644
--- a/src/Icons/Dockerfile
+++ b/src/Icons/Dockerfile
@@ -1,6 +1,49 @@
+###############################################
+# Build stage #
+###############################################
+FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+
+# Docker buildx supplies the value for this arg
+ARG TARGETPLATFORM
+
+# Determine proper runtime value for .NET
+RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
+ RID=linux-x64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
+ RID=linux-arm64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
+ RID=linux-arm ; \
+ fi \
+ && echo "RID=$RID" > /tmp/rid.txt
+
+# Copy required project files
+WORKDIR /source
+COPY . ./
+
+# Restore project dependencies and tools
+WORKDIR /source/src/Icons
+RUN . /tmp/rid.txt && dotnet restore -r $RID
+
+# Build project
+RUN . /tmp/rid.txt && dotnet publish \
+ -c release \
+ --no-restore \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ -r $RID \
+ -o out
+
+###############################################
+# App stage #
+###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
+ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
+ENV ASPNETCORE_ENVIRONMENT=Production
+ENV ASPNETCORE_URLS=http://+:5000
+ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
+EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -8,13 +51,11 @@ RUN apt-get update \
curl \
&& rm -rf /var/lib/apt/lists/*
-ENV ASPNETCORE_URLS http://+:5000
+# Copy app from the build stage
WORKDIR /app
-EXPOSE 5000
-COPY obj/build-output/publish .
-COPY entrypoint.sh /
+COPY --from=build /source/src/Icons/out /app
+COPY ./src/Icons/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
-
HEALTHCHECK CMD curl -f http://localhost:5000/google.com/icon.png || exit 1
ENTRYPOINT ["/entrypoint.sh"]
diff --git a/src/Icons/entrypoint.sh b/src/Icons/entrypoint.sh
index 9ed16fba23..c65d3b308d 100644
--- a/src/Icons/entrypoint.sh
+++ b/src/Icons/entrypoint.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Setup
@@ -19,24 +19,36 @@ then
LGID=65534
fi
-# Create user and group
+if [ "$(id -u)" = "0" ]
+then
+ # Create user and group
-groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
-groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
-useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
-usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
-mkhomedir_helper $USERNAME
+ groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
+ groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
+ useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
+ usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
+ mkhomedir_helper $USERNAME
-# The rest...
+ # The rest...
-chown -R $USERNAME:$GROUPNAME /app
-mkdir -p /etc/bitwarden/logs
-mkdir -p /etc/bitwarden/ca-certificates
-chown -R $USERNAME:$GROUPNAME /etc/bitwarden
+ chown -R $USERNAME:$GROUPNAME /app
+ mkdir -p /etc/bitwarden/core
+ mkdir -p /etc/bitwarden/logs
+ mkdir -p /etc/bitwarden/ca-certificates
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden
-if [[ $globalSettings__selfHosted == "true" ]]; then
- cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
- && update-ca-certificates
+ if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
+ fi
+
+ gosu_cmd="gosu $USERNAME:$GROUPNAME"
+else
+ gosu_cmd=""
fi
-exec gosu $USERNAME:$GROUPNAME dotnet /app/Icons.dll
+if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
+ cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
+ $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
+fi
+
+exec $gosu_cmd /app/Icons
diff --git a/src/Identity/.dockerignore b/src/Identity/.dockerignore
deleted file mode 100644
index fc12f25146..0000000000
--- a/src/Identity/.dockerignore
+++ /dev/null
@@ -1,4 +0,0 @@
-*
-!obj/build-output/publish/*
-!obj/Docker/empty/
-!entrypoint.sh
diff --git a/src/Identity/Dockerfile b/src/Identity/Dockerfile
index 050859a496..9b9ae41334 100644
--- a/src/Identity/Dockerfile
+++ b/src/Identity/Dockerfile
@@ -1,6 +1,50 @@
+###############################################
+# Build stage #
+###############################################
+FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+
+# Docker buildx supplies the value for this arg
+ARG TARGETPLATFORM
+
+# Determine proper runtime value for .NET
+# We put the value in a file to be read by later layers.
+RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
+ RID=linux-x64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
+ RID=linux-arm64 ; \
+ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
+ RID=linux-arm ; \
+ fi \
+ && echo "RID=$RID" > /tmp/rid.txt
+
+# Copy required project files
+WORKDIR /source
+COPY . ./
+
+# Restore project dependencies and tools
+WORKDIR /source/src/Identity
+RUN . /tmp/rid.txt && dotnet restore -r $RID
+
+# Build project
+RUN . /tmp/rid.txt && dotnet publish \
+ -c release \
+ --no-restore \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ -r $RID \
+ -o out
+
+###############################################
+# App stage #
+###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
+ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
+ENV ASPNETCORE_ENVIRONMENT=Production
+ENV ASPNETCORE_URLS=http://+:5000
+ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
+EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -9,13 +53,11 @@ RUN apt-get update \
krb5-user \
&& rm -rf /var/lib/apt/lists/*
-ENV ASPNETCORE_URLS http://+:5000
+# Copy app from the build stage
WORKDIR /app
-EXPOSE 5000
-COPY obj/build-output/publish .
-COPY entrypoint.sh /
+COPY --from=build /source/src/Identity/out /app
+COPY ./src/Identity/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
-
HEALTHCHECK CMD curl -f http://localhost:5000/.well-known/openid-configuration || exit 1
ENTRYPOINT ["/entrypoint.sh"]
diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj
index cb506d86e9..bf5ab82166 100644
--- a/src/Identity/Identity.csproj
+++ b/src/Identity/Identity.csproj
@@ -12,4 +12,8 @@
+
+
+
+
diff --git a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs
new file mode 100644
index 0000000000..a7e2754f00
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs
@@ -0,0 +1,51 @@
+using Bit.Core.IdentityServer;
+using Bit.Core.Platform.Installations;
+using Duende.IdentityServer.Models;
+using IdentityModel;
+
+namespace Bit.Identity.IdentityServer.ClientProviders;
+
+internal class InstallationClientProvider : IClientProvider
+{
+ private readonly IInstallationRepository _installationRepository;
+
+ public InstallationClientProvider(IInstallationRepository installationRepository)
+ {
+ _installationRepository = installationRepository;
+ }
+
+ public async Task GetAsync(string identifier)
+ {
+ if (!Guid.TryParse(identifier, out var installationId))
+ {
+ return null;
+ }
+
+ var installation = await _installationRepository.GetByIdAsync(installationId);
+
+ if (installation == null)
+ {
+ return null;
+ }
+
+ return new Client
+ {
+ ClientId = $"installation.{installation.Id}",
+ RequireClientSecret = true,
+ ClientSecrets = { new Secret(installation.Key.Sha256()) },
+ AllowedScopes = new[]
+ {
+ ApiScopes.ApiPush,
+ ApiScopes.ApiLicensing,
+ ApiScopes.ApiInstallation,
+ },
+ AllowedGrantTypes = GrantTypes.ClientCredentials,
+ AccessTokenLifetime = 3600 * 24,
+ Enabled = installation.Enabled,
+ Claims = new List
+ {
+ new(JwtClaimTypes.Subject, installation.Id.ToString()),
+ },
+ };
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs
new file mode 100644
index 0000000000..6d7fdc3459
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs
@@ -0,0 +1,40 @@
+#nullable enable
+
+using System.Diagnostics;
+using Bit.Core.IdentityServer;
+using Bit.Core.Settings;
+using Duende.IdentityServer.Models;
+using IdentityModel;
+
+namespace Bit.Identity.IdentityServer.ClientProviders;
+
+internal class InternalClientProvider : IClientProvider
+{
+ private readonly GlobalSettings _globalSettings;
+
+ public InternalClientProvider(GlobalSettings globalSettings)
+ {
+ // This class should not have been registered when it's not self hosted
+ Debug.Assert(globalSettings.SelfHosted);
+
+ _globalSettings = globalSettings;
+ }
+
+ public Task GetAsync(string identifier)
+ {
+ return Task.FromResult(new Client
+ {
+ ClientId = $"internal.{identifier}",
+ RequireClientSecret = true,
+ ClientSecrets = { new Secret(_globalSettings.InternalIdentityKey.Sha256()) },
+ AllowedScopes = [ApiScopes.Internal],
+ AllowedGrantTypes = GrantTypes.ClientCredentials,
+ AccessTokenLifetime = 3600 * 24,
+ Enabled = true,
+ Claims =
+ [
+ new(JwtClaimTypes.Subject, identifier),
+ ],
+ });
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs
new file mode 100644
index 0000000000..76842a9e54
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs
@@ -0,0 +1,58 @@
+using Bit.Core.Enums;
+using Bit.Core.Identity;
+using Bit.Core.IdentityServer;
+using Bit.Core.Repositories;
+using Duende.IdentityServer.Models;
+using IdentityModel;
+
+namespace Bit.Identity.IdentityServer.ClientProviders;
+
+internal class OrganizationClientProvider : IClientProvider
+{
+ private readonly IOrganizationRepository _organizationRepository;
+ private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
+
+ public OrganizationClientProvider(
+ IOrganizationRepository organizationRepository,
+ IOrganizationApiKeyRepository organizationApiKeyRepository
+ )
+ {
+ _organizationRepository = organizationRepository;
+ _organizationApiKeyRepository = organizationApiKeyRepository;
+ }
+
+ public async Task GetAsync(string identifier)
+ {
+ if (!Guid.TryParse(identifier, out var organizationId))
+ {
+ return null;
+ }
+
+ var organization = await _organizationRepository.GetByIdAsync(organizationId);
+
+ if (organization == null)
+ {
+ return null;
+ }
+
+ var orgApiKey = (await _organizationApiKeyRepository
+ .GetManyByOrganizationIdTypeAsync(organization.Id, OrganizationApiKeyType.Default))
+ .First();
+
+ return new Client
+ {
+ ClientId = $"organization.{organization.Id}",
+ RequireClientSecret = true,
+ ClientSecrets = [new Secret(orgApiKey.ApiKey.Sha256())],
+ AllowedScopes = [ApiScopes.ApiOrganization],
+ AllowedGrantTypes = GrantTypes.ClientCredentials,
+ AccessTokenLifetime = 3600 * 1,
+ Enabled = organization.Enabled && organization.UseApi,
+ Claims =
+ [
+ new(JwtClaimTypes.Subject, organization.Id.ToString()),
+ new(Claims.Type, IdentityClientType.Organization.ToString())
+ ],
+ };
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs
new file mode 100644
index 0000000000..dec5f8dc64
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs
@@ -0,0 +1,76 @@
+using Bit.Core.Identity;
+using Bit.Core.Repositories;
+using Bit.Core.SecretsManager.Models.Data;
+using Bit.Core.SecretsManager.Repositories;
+using Duende.IdentityServer.Models;
+using IdentityModel;
+
+namespace Bit.Identity.IdentityServer.ClientProviders;
+
+internal class SecretsManagerApiKeyProvider : IClientProvider
+{
+ public const string ApiKeyPrefix = "apikey";
+
+ private readonly IApiKeyRepository _apiKeyRepository;
+ private readonly IOrganizationRepository _organizationRepository;
+
+ public SecretsManagerApiKeyProvider(IApiKeyRepository apiKeyRepository, IOrganizationRepository organizationRepository)
+ {
+ _apiKeyRepository = apiKeyRepository;
+ _organizationRepository = organizationRepository;
+ }
+
+ public async Task GetAsync(string identifier)
+ {
+ if (!Guid.TryParse(identifier, out var apiKeyId))
+ {
+ return null;
+ }
+
+ var apiKey = await _apiKeyRepository.GetDetailsByIdAsync(apiKeyId);
+
+ if (apiKey == null || apiKey.ExpireAt <= DateTime.UtcNow)
+ {
+ return null;
+ }
+
+ switch (apiKey)
+ {
+ case ServiceAccountApiKeyDetails key:
+ var org = await _organizationRepository.GetByIdAsync(key.ServiceAccountOrganizationId);
+ if (!org.UseSecretsManager || !org.Enabled)
+ {
+ return null;
+ }
+ break;
+ }
+
+ var client = new Client
+ {
+ ClientId = identifier,
+ RequireClientSecret = true,
+ ClientSecrets = { new Secret(apiKey.ClientSecretHash) },
+ AllowedScopes = apiKey.GetScopes(),
+ AllowedGrantTypes = GrantTypes.ClientCredentials,
+ AccessTokenLifetime = 3600 * 1,
+ ClientClaimsPrefix = null,
+ Properties = new Dictionary {
+ {"encryptedPayload", apiKey.EncryptedPayload},
+ },
+ Claims = new List
+ {
+ new(JwtClaimTypes.Subject, apiKey.ServiceAccountId.ToString()),
+ new(Claims.Type, IdentityClientType.ServiceAccount.ToString()),
+ },
+ };
+
+ switch (apiKey)
+ {
+ case ServiceAccountApiKeyDetails key:
+ client.Claims.Add(new ClientClaim(Claims.Organization, key.ServiceAccountOrganizationId.ToString()));
+ break;
+ }
+
+ return client;
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs
new file mode 100644
index 0000000000..82abfa3536
--- /dev/null
+++ b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs
@@ -0,0 +1,82 @@
+#nullable enable
+
+using System.Collections.ObjectModel;
+using System.Security.Claims;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Context;
+using Bit.Core.Identity;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Utilities;
+using Duende.IdentityServer.Models;
+using IdentityModel;
+
+namespace Bit.Identity.IdentityServer.ClientProviders;
+
+public class UserClientProvider : IClientProvider
+{
+ private readonly IUserRepository _userRepository;
+ private readonly ICurrentContext _currentContext;
+ private readonly ILicensingService _licensingService;
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+ private readonly IProviderUserRepository _providerUserRepository;
+
+ public UserClientProvider(
+ IUserRepository userRepository,
+ ICurrentContext currentContext,
+ ILicensingService licensingService,
+ IOrganizationUserRepository organizationUserRepository,
+ IProviderUserRepository providerUserRepository)
+ {
+ _userRepository = userRepository;
+ _currentContext = currentContext;
+ _licensingService = licensingService;
+ _organizationUserRepository = organizationUserRepository;
+ _providerUserRepository = providerUserRepository;
+ }
+
+ public async Task GetAsync(string identifier)
+ {
+ if (!Guid.TryParse(identifier, out var userId))
+ {
+ return null;
+ }
+
+ var user = await _userRepository.GetByIdAsync(userId);
+ if (user == null)
+ {
+ return null;
+ }
+
+ var claims = new Collection
+ {
+ new(JwtClaimTypes.Subject, user.Id.ToString()),
+ new(JwtClaimTypes.AuthenticationMethod, "Application", "external"),
+ new(Claims.Type, IdentityClientType.User.ToString()),
+ };
+ var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id);
+ var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id);
+ var isPremium = await _licensingService.ValidateUserPremiumAsync(user);
+ foreach (var claim in CoreHelpers.BuildIdentityClaims(user, orgs, providers, isPremium))
+ {
+ var upperValue = claim.Value.ToUpperInvariant();
+ var isBool = upperValue is "TRUE" or "FALSE";
+ claims.Add(isBool
+ ? new ClientClaim(claim.Key, claim.Value, ClaimValueTypes.Boolean)
+ : new ClientClaim(claim.Key, claim.Value)
+ );
+ }
+
+ return new Client
+ {
+ ClientId = $"user.{userId}",
+ RequireClientSecret = true,
+ ClientSecrets = { new Secret(user.ApiKey.Sha256()) },
+ AllowedScopes = new[] { "api" },
+ AllowedGrantTypes = GrantTypes.ClientCredentials,
+ AccessTokenLifetime = 3600 * 1,
+ ClientClaimsPrefix = null,
+ Claims = claims,
+ };
+ }
+}
diff --git a/src/Identity/IdentityServer/ClientStore.cs b/src/Identity/IdentityServer/ClientStore.cs
deleted file mode 100644
index c204e364ce..0000000000
--- a/src/Identity/IdentityServer/ClientStore.cs
+++ /dev/null
@@ -1,291 +0,0 @@
-using System.Collections.ObjectModel;
-using System.Security.Claims;
-using Bit.Core.AdminConsole.Repositories;
-using Bit.Core.Context;
-using Bit.Core.Enums;
-using Bit.Core.Identity;
-using Bit.Core.IdentityServer;
-using Bit.Core.Platform.Installations;
-using Bit.Core.Repositories;
-using Bit.Core.SecretsManager.Models.Data;
-using Bit.Core.SecretsManager.Repositories;
-using Bit.Core.Services;
-using Bit.Core.Settings;
-using Bit.Core.Utilities;
-using Duende.IdentityServer.Models;
-using Duende.IdentityServer.Stores;
-using IdentityModel;
-
-namespace Bit.Identity.IdentityServer;
-
-public class ClientStore : IClientStore
-{
- private readonly IInstallationRepository _installationRepository;
- private readonly IOrganizationRepository _organizationRepository;
- private readonly IUserRepository _userRepository;
- private readonly GlobalSettings _globalSettings;
- private readonly StaticClientStore _staticClientStore;
- private readonly ILicensingService _licensingService;
- private readonly ICurrentContext _currentContext;
- private readonly IOrganizationUserRepository _organizationUserRepository;
- private readonly IProviderUserRepository _providerUserRepository;
- private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
- private readonly IApiKeyRepository _apiKeyRepository;
-
- public ClientStore(
- IInstallationRepository installationRepository,
- IOrganizationRepository organizationRepository,
- IUserRepository userRepository,
- GlobalSettings globalSettings,
- StaticClientStore staticClientStore,
- ILicensingService licensingService,
- ICurrentContext currentContext,
- IOrganizationUserRepository organizationUserRepository,
- IProviderUserRepository providerUserRepository,
- IOrganizationApiKeyRepository organizationApiKeyRepository,
- IApiKeyRepository apiKeyRepository)
- {
- _installationRepository = installationRepository;
- _organizationRepository = organizationRepository;
- _userRepository = userRepository;
- _globalSettings = globalSettings;
- _staticClientStore = staticClientStore;
- _licensingService = licensingService;
- _currentContext = currentContext;
- _organizationUserRepository = organizationUserRepository;
- _providerUserRepository = providerUserRepository;
- _organizationApiKeyRepository = organizationApiKeyRepository;
- _apiKeyRepository = apiKeyRepository;
- }
-
- public async Task FindClientByIdAsync(string clientId)
- {
- if (!_globalSettings.SelfHosted && clientId.StartsWith("installation."))
- {
- return await CreateInstallationClientAsync(clientId);
- }
-
- if (_globalSettings.SelfHosted && clientId.StartsWith("internal.") &&
- CoreHelpers.SettingHasValue(_globalSettings.InternalIdentityKey))
- {
- return CreateInternalClient(clientId);
- }
-
- if (clientId.StartsWith("organization."))
- {
- return await CreateOrganizationClientAsync(clientId);
- }
-
- if (clientId.StartsWith("user."))
- {
- return await CreateUserClientAsync(clientId);
- }
-
- if (_staticClientStore.ApiClients.TryGetValue(clientId, out var client))
- {
- return client;
- }
-
- return await CreateApiKeyClientAsync(clientId);
- }
-
- private async Task CreateApiKeyClientAsync(string clientId)
- {
- if (!Guid.TryParse(clientId, out var guid))
- {
- return null;
- }
-
- var apiKey = await _apiKeyRepository.GetDetailsByIdAsync(guid);
-
- if (apiKey == null || apiKey.ExpireAt <= DateTime.Now)
- {
- return null;
- }
-
- switch (apiKey)
- {
- case ServiceAccountApiKeyDetails key:
- var org = await _organizationRepository.GetByIdAsync(key.ServiceAccountOrganizationId);
- if (!org.UseSecretsManager || !org.Enabled)
- {
- return null;
- }
- break;
- }
-
- var client = new Client
- {
- ClientId = clientId,
- RequireClientSecret = true,
- ClientSecrets = { new Secret(apiKey.ClientSecretHash) },
- AllowedScopes = apiKey.GetScopes(),
- AllowedGrantTypes = GrantTypes.ClientCredentials,
- AccessTokenLifetime = 3600 * 1,
- ClientClaimsPrefix = null,
- Properties = new Dictionary {
- {"encryptedPayload", apiKey.EncryptedPayload},
- },
- Claims = new List
- {
- new(JwtClaimTypes.Subject, apiKey.ServiceAccountId.ToString()),
- new(Claims.Type, IdentityClientType.ServiceAccount.ToString()),
- },
- };
-
- switch (apiKey)
- {
- case ServiceAccountApiKeyDetails key:
- client.Claims.Add(new ClientClaim(Claims.Organization, key.ServiceAccountOrganizationId.ToString()));
- break;
- }
-
- return client;
- }
-
- private async Task CreateUserClientAsync(string clientId)
- {
- var idParts = clientId.Split('.');
- if (idParts.Length <= 1 || !Guid.TryParse(idParts[1], out var id))
- {
- return null;
- }
-
- var user = await _userRepository.GetByIdAsync(id);
- if (user == null)
- {
- return null;
- }
-
- var claims = new Collection
- {
- new(JwtClaimTypes.Subject, user.Id.ToString()),
- new(JwtClaimTypes.AuthenticationMethod, "Application", "external"),
- new(Claims.Type, IdentityClientType.User.ToString()),
- };
- var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id);
- var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id);
- var isPremium = await _licensingService.ValidateUserPremiumAsync(user);
- foreach (var claim in CoreHelpers.BuildIdentityClaims(user, orgs, providers, isPremium))
- {
- var upperValue = claim.Value.ToUpperInvariant();
- var isBool = upperValue is "TRUE" or "FALSE";
- claims.Add(isBool
- ? new ClientClaim(claim.Key, claim.Value, ClaimValueTypes.Boolean)
- : new ClientClaim(claim.Key, claim.Value)
- );
- }
-
- return new Client
- {
- ClientId = clientId,
- RequireClientSecret = true,
- ClientSecrets = { new Secret(user.ApiKey.Sha256()) },
- AllowedScopes = new[] { "api" },
- AllowedGrantTypes = GrantTypes.ClientCredentials,
- AccessTokenLifetime = 3600 * 1,
- ClientClaimsPrefix = null,
- Claims = claims,
- };
- }
-
- private async Task CreateOrganizationClientAsync(string clientId)
- {
- var idParts = clientId.Split('.');
- if (idParts.Length <= 1 || !Guid.TryParse(idParts[1], out var id))
- {
- return null;
- }
-
- var org = await _organizationRepository.GetByIdAsync(id);
- if (org == null)
- {
- return null;
- }
-
- var orgApiKey = (await _organizationApiKeyRepository
- .GetManyByOrganizationIdTypeAsync(org.Id, OrganizationApiKeyType.Default))
- .First();
-
- return new Client
- {
- ClientId = $"organization.{org.Id}",
- RequireClientSecret = true,
- ClientSecrets = { new Secret(orgApiKey.ApiKey.Sha256()) },
- AllowedScopes = new[] { ApiScopes.ApiOrganization },
- AllowedGrantTypes = GrantTypes.ClientCredentials,
- AccessTokenLifetime = 3600 * 1,
- Enabled = org.Enabled && org.UseApi,
- Claims = new List
- {
- new(JwtClaimTypes.Subject, org.Id.ToString()),
- new(Claims.Type, IdentityClientType.Organization.ToString()),
- },
- };
- }
-
- private Client CreateInternalClient(string clientId)
- {
- var idParts = clientId.Split('.');
- if (idParts.Length <= 1)
- {
- return null;
- }
-
- var id = idParts[1];
- if (string.IsNullOrWhiteSpace(id))
- {
- return null;
- }
-
- return new Client
- {
- ClientId = $"internal.{id}",
- RequireClientSecret = true,
- ClientSecrets = { new Secret(_globalSettings.InternalIdentityKey.Sha256()) },
- AllowedScopes = new[] { ApiScopes.Internal },
- AllowedGrantTypes = GrantTypes.ClientCredentials,
- AccessTokenLifetime = 3600 * 24,
- Enabled = true,
- Claims = new List
- {
- new(JwtClaimTypes.Subject, id),
- },
- };
- }
-
- private async Task CreateInstallationClientAsync(string clientId)
- {
- var idParts = clientId.Split('.');
- if (idParts.Length <= 1 || !Guid.TryParse(idParts[1], out Guid id))
- {
- return null;
- }
-
- var installation = await _installationRepository.GetByIdAsync(id);
- if (installation == null)
- {
- return null;
- }
-
- return new Client
- {
- ClientId = $"installation.{installation.Id}",
- RequireClientSecret = true,
- ClientSecrets = { new Secret(installation.Key.Sha256()) },
- AllowedScopes = new[]
- {
- ApiScopes.ApiPush,
- ApiScopes.ApiLicensing,
- ApiScopes.ApiInstallation,
- },
- AllowedGrantTypes = GrantTypes.ClientCredentials,
- AccessTokenLifetime = 3600 * 24,
- Enabled = installation.Enabled,
- Claims = new List
- {
- new(JwtClaimTypes.Subject, installation.Id.ToString()),
- },
- };
- }
-}
diff --git a/src/Identity/IdentityServer/DynamicClientStore.cs b/src/Identity/IdentityServer/DynamicClientStore.cs
new file mode 100644
index 0000000000..9d7764bf42
--- /dev/null
+++ b/src/Identity/IdentityServer/DynamicClientStore.cs
@@ -0,0 +1,75 @@
+#nullable enable
+
+using Bit.Identity.IdentityServer.ClientProviders;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Stores;
+
+namespace Bit.Identity.IdentityServer;
+
+public interface IClientProvider
+{
+ Task GetAsync(string identifier);
+}
+
+internal class DynamicClientStore : IClientStore
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IClientProvider _apiKeyClientProvider;
+ private readonly StaticClientStore _staticClientStore;
+
+ public DynamicClientStore(
+ IServiceProvider serviceProvider,
+ [FromKeyedServices(SecretsManagerApiKeyProvider.ApiKeyPrefix)] IClientProvider apiKeyClientProvider,
+ StaticClientStore staticClientStore
+ )
+ {
+ _serviceProvider = serviceProvider;
+ _apiKeyClientProvider = apiKeyClientProvider;
+ _staticClientStore = staticClientStore;
+ }
+
+ public Task FindClientByIdAsync(string clientId)
+ {
+ var clientIdSpan = clientId.AsSpan();
+
+ var firstPeriod = clientIdSpan.IndexOf('.');
+
+ if (firstPeriod == -1)
+ {
+ // No splitter, attempt but don't fail for a static client
+ if (_staticClientStore.ApiClients.TryGetValue(clientId, out var client))
+ {
+ return Task.FromResult(client);
+ }
+ }
+ else
+ {
+ // Increment past the period
+ var identifierName = clientIdSpan[..firstPeriod++];
+
+ var identifier = clientIdSpan[firstPeriod..];
+
+ // The identifier is required to be non-empty
+ if (identifier.IsEmpty || identifier.IsWhiteSpace())
+ {
+ return Task.FromResult(null);
+ }
+
+ // Once identifierName is proven valid, materialize the string
+ var clientBuilder = _serviceProvider.GetKeyedService(identifierName.ToString());
+
+ if (clientBuilder == null)
+ {
+ // No client registered by this identifier
+ return Task.FromResult(null);
+ }
+
+ return clientBuilder.GetAsync(identifier.ToString());
+ }
+
+ // It could be an ApiKey, give them the full thing to try,
+ // this is a special case for legacy reasons, no other client should
+ // be allowed without a prefixing identifier.
+ return _apiKeyClientProvider.GetAsync(clientId);
+ }
+}
diff --git a/src/Identity/IdentityServer/ProfileService.cs b/src/Identity/IdentityServer/ProfileService.cs
index 09866c6b57..d7d6708374 100644
--- a/src/Identity/IdentityServer/ProfileService.cs
+++ b/src/Identity/IdentityServer/ProfileService.cs
@@ -72,6 +72,10 @@ public class ProfileService : IProfileService
public async Task IsActiveAsync(IsActiveContext context)
{
+ // We add the security stamp claim to the persisted grant when we issue the refresh token.
+ // IdentityServer will add this claim to the subject, and here we evaluate whether the security stamp that
+ // was persisted matches the current security stamp of the user. If it does not match, then the user has performed
+ // an operation that we want to invalidate the refresh token.
var securityTokenClaim = context.Subject?.Claims.FirstOrDefault(c => c.Type == Claims.SecurityStamp);
var user = await _userService.GetUserByPrincipalAsync(context.Subject);
diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
index 9afdcacf14..45c0c26b17 100644
--- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
@@ -199,46 +199,26 @@ public abstract class BaseRequestValidator where T : class
protected abstract Task ValidateContextAsync(T context, CustomValidatorRequestContext validatorContext);
+
+ ///
+ /// Responsible for building the response to the client when the user has successfully authenticated.
+ ///
+ /// The authenticated user.
+ /// The current request context.
+ /// The device used for authentication.
+ /// Whether to send a 2FA remember token.
protected async Task BuildSuccessResultAsync(User user, T context, Device device, bool sendRememberToken)
{
await _eventService.LogUserEventAsync(user.Id, EventType.User_LoggedIn);
- var claims = new List();
+ var claims = this.BuildSubjectClaims(user, context, device);
- if (device != null)
- {
- claims.Add(new Claim(Claims.Device, device.Identifier));
- claims.Add(new Claim(Claims.DeviceType, device.Type.ToString()));
- }
-
- var customResponse = new Dictionary();
- if (!string.IsNullOrWhiteSpace(user.PrivateKey))
- {
- customResponse.Add("PrivateKey", user.PrivateKey);
- }
-
- if (!string.IsNullOrWhiteSpace(user.Key))
- {
- customResponse.Add("Key", user.Key);
- }
-
- customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
- customResponse.Add("ForcePasswordReset", user.ForcePasswordReset);
- customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword));
- customResponse.Add("Kdf", (byte)user.Kdf);
- customResponse.Add("KdfIterations", user.KdfIterations);
- customResponse.Add("KdfMemory", user.KdfMemory);
- customResponse.Add("KdfParallelism", user.KdfParallelism);
- customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
-
- if (sendRememberToken)
- {
- var token = await _userManager.GenerateTwoFactorTokenAsync(user,
- CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember));
- customResponse.Add("TwoFactorToken", token);
- }
+ var customResponse = await BuildCustomResponse(user, context, device, sendRememberToken);
await ResetFailedAuthDetailsAsync(user);
+
+ // Once we've built the claims and custom response, we can set the success result.
+ // We delegate this to the derived classes, as the implementation varies based on the grant type.
await SetSuccessResult(context, user, claims, customResponse);
}
@@ -392,6 +372,71 @@ public abstract class BaseRequestValidator where T : class
return new MasterPasswordPolicyResponseModel(await PolicyService.GetMasterPasswordPolicyForUserAsync(user));
}
+ ///
+ /// Builds the claims that will be stored on the persisted grant.
+ /// These claims are supplemented by the claims in the ProfileService when the access token is returned to the client.
+ ///
+ /// The authenticated user.
+ /// The current request context.
+ /// The device used for authentication.
+ private List BuildSubjectClaims(User user, T context, Device device)
+ {
+ // We are adding the security stamp claim to the list of claims that will be stored in the persisted grant.
+ // We need this because we check for changes in the stamp to determine if we need to invalidate token refresh requests,
+ // in the `ProfileService.IsActiveAsync` method.
+ // If we don't store the security stamp in the persisted grant, we won't have the previous value to compare against.
+ var claims = new List
+ {
+ new Claim(Claims.SecurityStamp, user.SecurityStamp)
+ };
+
+ if (device != null)
+ {
+ claims.Add(new Claim(Claims.Device, device.Identifier));
+ claims.Add(new Claim(Claims.DeviceType, device.Type.ToString()));
+ }
+ return claims;
+ }
+
+ ///
+ /// Builds the custom response that will be sent to the client upon successful authentication, which
+ /// includes the information needed for the client to initialize the user's account in state.
+ ///
+ /// The authenticated user.
+ /// The current request context.
+ /// The device used for authentication.
+ /// Whether to send a 2FA remember token.
+ private async Task> BuildCustomResponse(User user, T context, Device device, bool sendRememberToken)
+ {
+ var customResponse = new Dictionary();
+ if (!string.IsNullOrWhiteSpace(user.PrivateKey))
+ {
+ customResponse.Add("PrivateKey", user.PrivateKey);
+ }
+
+ if (!string.IsNullOrWhiteSpace(user.Key))
+ {
+ customResponse.Add("Key", user.Key);
+ }
+
+ customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
+ customResponse.Add("ForcePasswordReset", user.ForcePasswordReset);
+ customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword));
+ customResponse.Add("Kdf", (byte)user.Kdf);
+ customResponse.Add("KdfIterations", user.KdfIterations);
+ customResponse.Add("KdfMemory", user.KdfMemory);
+ customResponse.Add("KdfParallelism", user.KdfParallelism);
+ customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
+
+ if (sendRememberToken)
+ {
+ var token = await _userManager.GenerateTwoFactorTokenAsync(user,
+ CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember));
+ customResponse.Add("TwoFactorToken", token);
+ }
+ return customResponse;
+ }
+
#nullable enable
///
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
diff --git a/src/Identity/IdentityServer/ServiceCollectionExtensions.cs b/src/Identity/IdentityServer/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..2402ebb7f9
--- /dev/null
+++ b/src/Identity/IdentityServer/ServiceCollectionExtensions.cs
@@ -0,0 +1,28 @@
+using Bit.Identity.IdentityServer;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers a custom for the given identifier to be called when a client id with
+ /// the identifier is attempting authentication.
+ ///
+ /// Your custom implementation of .
+ /// The service collection to add services to.
+ ///
+ /// The identifier to be used to invoke your client provider if a client_id is prefixed with your identifier
+ /// then your implementation will be invoked with the data after the seperating ..
+ ///
+ /// The for additional chaining.
+ public static IServiceCollection AddClientProvider(this IServiceCollection services, string identifier)
+ where T : class, IClientProvider
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
+
+ services.AddKeyedTransient(identifier);
+
+ return services;
+ }
+}
diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs
index bf90b1aa24..1476a5ec76 100644
--- a/src/Identity/Utilities/ServiceCollectionExtensions.cs
+++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@ using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer;
+using Bit.Identity.IdentityServer.ClientProviders;
using Bit.Identity.IdentityServer.RequestValidators;
using Bit.SharedWeb.Utilities;
using Duende.IdentityServer.ResponseHandling;
@@ -48,14 +49,29 @@ public static class ServiceCollectionExtensions
.AddInMemoryCaching()
.AddInMemoryApiResources(ApiResources.GetApiResources())
.AddInMemoryApiScopes(ApiScopes.GetApiScopes())
- .AddClientStoreCache()
+ .AddClientStoreCache()
.AddCustomTokenRequestValidator()
.AddProfileService()
.AddResourceOwnerValidator()
- .AddClientStore()
+ .AddClientStore()
.AddIdentityServerCertificate(env, globalSettings)
.AddExtensionGrantValidator();
+ if (!globalSettings.SelfHosted)
+ {
+ // Only cloud instances should be able to handle installations
+ services.AddClientProvider("installation");
+ }
+
+ if (globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey))
+ {
+ services.AddClientProvider("internal");
+ }
+
+ services.AddClientProvider("user");
+ services.AddClientProvider("organization");
+ services.AddClientProvider(SecretsManagerApiKeyProvider.ApiKeyPrefix);
+
if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CosmosConnectionString))
{
services.AddSingleton(sp =>
diff --git a/src/Identity/entrypoint.sh b/src/Identity/entrypoint.sh
index cf59bee472..f5f84cc220 100644
--- a/src/Identity/entrypoint.sh
+++ b/src/Identity/entrypoint.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Setup
@@ -19,37 +19,42 @@ then
LGID=65534
fi
-# Create user and group
+if [ "$(id -u)" = "0" ]
+then
+ # Create user and group
-groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
-groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
-useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
-usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
-mkhomedir_helper $USERNAME
+ groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
+ groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
+ useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
+ usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
+ mkhomedir_helper $USERNAME
-# The rest...
+ # The rest...
-mkdir -p /etc/bitwarden/identity
-mkdir -p /etc/bitwarden/core
-mkdir -p /etc/bitwarden/logs
-mkdir -p /etc/bitwarden/ca-certificates
-chown -R $USERNAME:$GROUPNAME /etc/bitwarden
+ chown -R $USERNAME:$GROUPNAME /app
+ mkdir -p /etc/bitwarden/core
+ mkdir -p /etc/bitwarden/logs
+ mkdir -p /etc/bitwarden/ca-certificates
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden
-if [[ $globalSettings__selfHosted == "true" ]]; then
- cp /etc/bitwarden/identity/identity.pfx /app/identity.pfx
-fi
+ if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
+ fi
-chown -R $USERNAME:$GROUPNAME /app
-
-if [[ $globalSettings__selfHosted == "true" ]]; then
- cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
- && update-ca-certificates
+ gosu_cmd="gosu $USERNAME:$GROUPNAME"
+else
+ gosu_cmd=""
fi
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
- chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
- cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
- gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
+ cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
+ $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
fi
-exec gosu $USERNAME:$GROUPNAME dotnet /app/Identity.dll
+if [[ $globalSettings__selfHosted == "true" ]]; then
+ if [[ -z $globalSettings__identityServer__certificateLocation ]]; then
+ export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
+ fi
+fi
+
+exec $gosu_cmd /app/Identity
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs
index 5ef59d51db..fc5626631a 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs
@@ -1,5 +1,4 @@
-using System.Diagnostics;
-using AutoMapper;
+using AutoMapper;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums;
@@ -446,15 +445,12 @@ public class OrganizationUserRepository : Repository /tmp/rid.txt
+
+# Copy required project files
+WORKDIR /source
+COPY . ./
+
+# Restore project dependencies and tools
+WORKDIR /source/src/Notifications
+RUN . /tmp/rid.txt && dotnet restore -r $RID
+
+# Build project
+RUN . /tmp/rid.txt && dotnet publish \
+ -c release \
+ --no-restore \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ -r $RID \
+ -o out
+
+###############################################
+# App stage #
+###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
+ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
+ENV ASPNETCORE_ENVIRONMENT=Production
+ENV ASPNETCORE_URLS=http://+:5000
+ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
+EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -8,13 +52,11 @@ RUN apt-get update \
curl \
&& rm -rf /var/lib/apt/lists/*
-ENV ASPNETCORE_URLS http://+:5000
+# Copy app from the build stage
WORKDIR /app
-EXPOSE 5000
-COPY obj/build-output/publish .
-COPY entrypoint.sh /
+COPY --from=build /source/src/Notifications/out /app
+COPY ./src/Notifications/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
-
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
ENTRYPOINT ["/entrypoint.sh"]
diff --git a/src/Notifications/entrypoint.sh b/src/Notifications/entrypoint.sh
index e1555b6c50..d95324de2f 100644
--- a/src/Notifications/entrypoint.sh
+++ b/src/Notifications/entrypoint.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Setup
@@ -19,24 +19,27 @@ then
LGID=65534
fi
-# Create user and group
+if [ "$(id -u)" = "0" ]
+then
+ # Create user and group
-groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
-groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
-useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
-usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
-mkhomedir_helper $USERNAME
+ groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
+ groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
+ useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
+ usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
+ mkhomedir_helper $USERNAME
-# The rest...
+ # The rest...
-chown -R $USERNAME:$GROUPNAME /app
-mkdir -p /etc/bitwarden/logs
-mkdir -p /etc/bitwarden/ca-certificates
-chown -R $USERNAME:$GROUPNAME /etc/bitwarden
+ chown -R $USERNAME:$GROUPNAME /app
+ mkdir -p /etc/bitwarden/core
+ mkdir -p /etc/bitwarden/logs
+ mkdir -p /etc/bitwarden/ca-certificates
+ chown -R $USERNAME:$GROUPNAME /etc/bitwarden
-if [[ $globalSettings__selfHosted == "true" ]]; then
- cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
- && update-ca-certificates
+ gosu_cmd="gosu $USERNAME:$GROUPNAME"
+else
+ gosu_cmd=""
fi
-exec gosu $USERNAME:$GROUPNAME dotnet /app/Notifications.dll
+exec $gosu_cmd /app/Notifications
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index e425cf7254..247d4c5d43 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -557,7 +557,7 @@ public static class ServiceCollectionExtensions
services.AddKeyedSingleton("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
- CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
+ CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName))
{
services.AddKeyedSingleton("broadcast");
}
@@ -589,86 +589,83 @@ public static class ServiceCollectionExtensions
return services;
}
+ private static IServiceCollection AddAzureServiceBusEventRepositoryListener(this IServiceCollection services, GlobalSettings globalSettings)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddKeyedSingleton("persistent");
+
+ services.AddSingleton(provider =>
+ new AzureServiceBusEventListenerService(
+ handler: provider.GetRequiredService(),
+ logger: provider.GetRequiredService>(),
+ globalSettings: globalSettings,
+ subscriptionName: globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName));
+
+ return services;
+ }
+
+ private static IServiceCollection AddAzureServiceBusIntegration(
+ this IServiceCollection services,
+ string eventSubscriptionName,
+ string integrationSubscriptionName,
+ IntegrationType integrationType,
+ GlobalSettings globalSettings)
+ where TConfig : class
+ where THandler : class, IIntegrationHandler
+ {
+ var routingKey = integrationType.ToRoutingKey();
+
+ services.AddSingleton();
+
+ services.AddKeyedSingleton(routingKey, (provider, _) =>
+ new EventIntegrationHandler(
+ integrationType,
+ provider.GetRequiredService(),
+ provider.GetRequiredService(),
+ provider.GetRequiredService(),
+ provider.GetRequiredService()));
+
+ services.AddSingleton(provider =>
+ new AzureServiceBusEventListenerService(
+ handler: provider.GetRequiredKeyedService(routingKey),
+ logger: provider.GetRequiredService>(),
+ globalSettings: globalSettings,
+ subscriptionName: eventSubscriptionName));
+
+ services.AddSingleton, THandler>();
+
+ services.AddSingleton(provider =>
+ new AzureServiceBusIntegrationListenerService(
+ handler: provider.GetRequiredService>(),
+ subscriptionName: integrationSubscriptionName,
+ logger: provider.GetRequiredService>(),
+ globalSettings: globalSettings));
+
+ return services;
+ }
+
public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings)
{
- if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
- CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
- {
- services.AddSingleton();
- services.AddSingleton();
- services.AddKeyedSingleton("persistent");
- services.AddSingleton(provider =>
- new AzureServiceBusEventListenerService(
- provider.GetRequiredService(),
- provider.GetRequiredService>(),
- globalSettings,
- globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName));
+ if (!CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) ||
+ !CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName))
+ return services;
+ services.AddAzureServiceBusEventRepositoryListener(globalSettings);
- services.AddSlackService(globalSettings);
- services.AddSingleton();
- services.AddSingleton(provider =>
- new AzureServiceBusEventListenerService(
- provider.GetRequiredService(),
- provider.GetRequiredService>(),
- globalSettings,
- globalSettings.EventLogging.AzureServiceBus.SlackSubscriptionName));
-
- services.AddSingleton();
- services.AddHttpClient(WebhookEventHandler.HttpClientName);
- services.AddSingleton(provider =>
- new AzureServiceBusEventListenerService(
- provider.GetRequiredService(),
- provider.GetRequiredService>(),
- globalSettings,
- globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName));
- }
-
- return services;
- }
-
- public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings)
- {
- if (IsRabbitMqEnabled(globalSettings))
- {
- services.AddRabbitMqEventRepositoryListener(globalSettings);
-
- services.AddSlackService(globalSettings);
- services.AddRabbitMqIntegration(
- globalSettings.EventLogging.RabbitMq.SlackEventsQueueName,
- globalSettings.EventLogging.RabbitMq.SlackIntegrationQueueName,
- globalSettings.EventLogging.RabbitMq.SlackIntegrationRetryQueueName,
- globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName,
- IntegrationType.Slack,
- globalSettings);
-
- services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
- services.AddRabbitMqIntegration(
- globalSettings.EventLogging.RabbitMq.WebhookEventsQueueName,
- globalSettings.EventLogging.RabbitMq.WebhookIntegrationQueueName,
- globalSettings.EventLogging.RabbitMq.WebhookIntegrationRetryQueueName,
- globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName,
- IntegrationType.Webhook,
- globalSettings);
- }
-
- return services;
- }
-
- public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings)
- {
- if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
- CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
- CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
- {
- services.AddHttpClient(SlackService.HttpClientName);
- services.AddSingleton();
- }
- else
- {
- services.AddSingleton();
- }
+ services.AddSlackService(globalSettings);
+ services.AddAzureServiceBusIntegration(
+ eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.SlackEventSubscriptionName,
+ integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.SlackIntegrationSubscriptionName,
+ integrationType: IntegrationType.Slack,
+ globalSettings: globalSettings);
+ services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
+ services.AddAzureServiceBusIntegration(
+ eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.WebhookEventSubscriptionName,
+ integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.WebhookIntegrationSubscriptionName,
+ integrationType: IntegrationType.Webhook,
+ globalSettings: globalSettings);
return services;
}
@@ -729,6 +726,36 @@ public static class ServiceCollectionExtensions
return services;
}
+ public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings)
+ {
+ if (!IsRabbitMqEnabled(globalSettings))
+ {
+ return services;
+ }
+
+ services.AddRabbitMqEventRepositoryListener(globalSettings);
+
+ services.AddSlackService(globalSettings);
+ services.AddRabbitMqIntegration(
+ globalSettings.EventLogging.RabbitMq.SlackEventsQueueName,
+ globalSettings.EventLogging.RabbitMq.SlackIntegrationQueueName,
+ globalSettings.EventLogging.RabbitMq.SlackIntegrationRetryQueueName,
+ globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName,
+ IntegrationType.Slack,
+ globalSettings);
+
+ services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
+ services.AddRabbitMqIntegration(
+ globalSettings.EventLogging.RabbitMq.WebhookEventsQueueName,
+ globalSettings.EventLogging.RabbitMq.WebhookIntegrationQueueName,
+ globalSettings.EventLogging.RabbitMq.WebhookIntegrationRetryQueueName,
+ globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName,
+ IntegrationType.Webhook,
+ globalSettings);
+
+ return services;
+ }
+
private static bool IsRabbitMqEnabled(GlobalSettings settings)
{
return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) &&
@@ -737,6 +764,23 @@ public static class ServiceCollectionExtensions
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName);
}
+ public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings)
+ {
+ if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
+ CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
+ CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
+ {
+ services.AddHttpClient(SlackService.HttpClientName);
+ services.AddSingleton();
+ }
+ else
+ {
+ services.AddSingleton();
+ }
+
+ return services;
+ }
+
public static void UseDefaultMiddleware(this IApplicationBuilder app,
IWebHostEnvironment env, GlobalSettings globalSettings)
{
diff --git a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs
index a8c3cf15a9..aff51b0d1d 100644
--- a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs
+++ b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs
@@ -52,7 +52,7 @@ public class OrganizationBillingControllerTests
{
sutProvider.GetDependency().OrganizationUser(organizationId).Returns(true);
sutProvider.GetDependency().GetMetadata(organizationId)
- .Returns(new OrganizationMetadata(true, true, true, true, true, true, true, null, null, null));
+ .Returns(new OrganizationMetadata(true, true, true, true, true, true, true, null, null, null, 0));
var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
diff --git a/test/Core.Test/AdminConsole/Models/Data/Integrations/IntegrationMessageTests.cs b/test/Core.Test/AdminConsole/Models/Data/Integrations/IntegrationMessageTests.cs
index 44774449c1..0946841347 100644
--- a/test/Core.Test/AdminConsole/Models/Data/Integrations/IntegrationMessageTests.cs
+++ b/test/Core.Test/AdminConsole/Models/Data/Integrations/IntegrationMessageTests.cs
@@ -20,6 +20,7 @@ public class IntegrationMessageTests
message.ApplyRetry(baseline);
Assert.Equal(3, message.RetryCount);
+ Assert.NotNull(message.DelayUntilDate);
Assert.True(message.DelayUntilDate > baseline);
}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs
index baf844acae..540bac4d1c 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs
@@ -1,5 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
@@ -29,7 +32,6 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers;
public class AcceptOrgUserCommandTests
{
private readonly IUserService _userService = Substitute.For();
- private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery = Substitute.For();
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For();
private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory();
@@ -166,9 +168,6 @@ public class AcceptOrgUserCommandTests
// Arrange
SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
- // User doesn't have 2FA enabled
- _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false);
-
// Organization they are trying to join requires 2FA
var twoFactorPolicy = new OrganizationUserPolicyDetails { OrganizationId = orgUser.OrganizationId };
sutProvider.GetDependency()
@@ -185,6 +184,107 @@ public class AcceptOrgUserCommandTests
exception.Message);
}
+ [Theory]
+ [BitAutoData]
+ public async Task AcceptOrgUserAsync_WithPolicyRequirementsEnabled_UserWithout2FAJoining2FARequiredOrg_ThrowsBadRequest(
+ SutProvider sutProvider,
+ User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
+ {
+ SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
+
+ sutProvider.GetDependency()
+ .IsEnabled(FeatureFlagKeys.PolicyRequirements)
+ .Returns(true);
+
+ // Organization they are trying to join requires 2FA
+ sutProvider.GetDependency()
+ .GetAsync(user.Id)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = orgUser.OrganizationId,
+ OrganizationUserStatus = OrganizationUserStatusType.Invited,
+ PolicyType = PolicyType.TwoFactorAuthentication,
+ }
+ ]));
+
+ var exception = await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService));
+
+ Assert.Equal("You cannot join this organization until you enable two-step login on your user account.",
+ exception.Message);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task AcceptOrgUserAsync_WithPolicyRequirementsEnabled_UserWith2FAJoining2FARequiredOrg_Succeeds(
+ SutProvider sutProvider,
+ User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
+ {
+ SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
+
+ sutProvider.GetDependency()
+ .IsEnabled(FeatureFlagKeys.PolicyRequirements)
+ .Returns(true);
+
+ // User has 2FA enabled
+ sutProvider.GetDependency()
+ .TwoFactorIsEnabledAsync(user)
+ .Returns(true);
+
+ // Organization they are trying to join requires 2FA
+ sutProvider.GetDependency()
+ .GetAsync(user.Id)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = orgUser.OrganizationId,
+ OrganizationUserStatus = OrganizationUserStatusType.Invited,
+ PolicyType = PolicyType.TwoFactorAuthentication,
+ }
+ ]));
+
+ await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .ReplaceAsync(Arg.Is(ou => ou.Status == OrganizationUserStatusType.Accepted));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task AcceptOrgUserAsync_WithPolicyRequirementsEnabled_UserJoiningOrgWithout2FARequirement_Succeeds(
+ SutProvider sutProvider,
+ User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
+ {
+ SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
+
+ sutProvider.GetDependency()
+ .IsEnabled(FeatureFlagKeys.PolicyRequirements)
+ .Returns(true);
+
+ // Organization they are trying to join doesn't require 2FA
+ sutProvider.GetDependency()
+ .GetAsync(user.Id)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = Guid.NewGuid(),
+ OrganizationUserStatus = OrganizationUserStatusType.Invited,
+ PolicyType = PolicyType.TwoFactorAuthentication,
+ }
+ ]));
+
+ await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .ReplaceAsync(Arg.Is(ou => ou.Status == OrganizationUserStatusType.Accepted));
+ }
+
[Theory]
[BitAutoData(OrganizationUserType.Admin)]
[BitAutoData(OrganizationUserType.Owner)]
@@ -647,9 +747,6 @@ public class AcceptOrgUserCommandTests
.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg)
.Returns(false);
- // User doesn't have 2FA enabled
- _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false);
-
// Org does not require 2FA
sutProvider.GetDependency().GetPoliciesApplicableToUserAsync(user.Id,
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited)
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
index 06335f668d..366d8cb2d6 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
@@ -1,6 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
@@ -321,4 +324,122 @@ public class ConfirmOrganizationUserCommandTests
Assert.Contains("User does not have two-step login enabled.", result[1].Item2);
Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2);
}
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorRequired_ThrowsBadRequestException(
+ Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var featureService = sutProvider.GetDependency();
+ var policyRequirementQuery = sutProvider.GetDependency();
+ var twoFactorIsEnabledQuery = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = user.Id;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+ featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
+ policyRequirementQuery.GetAsync(user.Id)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = org.Id,
+ OrganizationUserStatus = OrganizationUserStatusType.Accepted,
+ PolicyType = PolicyType.TwoFactorAuthentication
+ }
+ ]));
+ twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) });
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, "key", confirmingUser.Id));
+ Assert.Contains("User does not have two-step login enabled.", exception.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorNotRequired_Succeeds(
+ Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var featureService = sutProvider.GetDependency();
+ var policyRequirementQuery = sutProvider.GetDependency();
+ var twoFactorIsEnabledQuery = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = user.Id;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+ featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
+ policyRequirementQuery.GetAsync(user.Id)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = Guid.NewGuid(),
+ OrganizationUserStatus = OrganizationUserStatusType.Invited,
+ PolicyType = PolicyType.TwoFactorAuthentication,
+ }
+ ]));
+ twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) });
+
+ await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, "key", confirmingUser.Id);
+
+ await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
+ await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager);
+ await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorEnabled_Succeeds(
+ Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var featureService = sutProvider.GetDependency();
+ var policyRequirementQuery = sutProvider.GetDependency();
+ var twoFactorIsEnabledQuery = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = user.Id;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+ featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
+ policyRequirementQuery.GetAsync(user.Id)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = org.Id,
+ OrganizationUserStatus = OrganizationUserStatusType.Accepted,
+ PolicyType = PolicyType.TwoFactorAuthentication
+ }
+ ]));
+ twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) });
+
+ await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, "key", confirmingUser.Id);
+
+ await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
+ await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager);
+ await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1));
+ }
}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs
index d6880a3a12..fbd711307c 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs
@@ -1,6 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
@@ -208,6 +211,57 @@ public class RestoreOrganizationUserCommandTests
.PushSyncOrgKeysAsync(Arg.Any());
}
+ [Theory, BitAutoData]
+ public async Task RestoreUser_WithPolicyRequirementsEnabled_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails(
+ Organization organization,
+ [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
+ [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
+ SutProvider sutProvider)
+ {
+ organizationUser.Email = null;
+
+ sutProvider.GetDependency()
+ .IsEnabled(FeatureFlagKeys.PolicyRequirements)
+ .Returns(true);
+
+ sutProvider.GetDependency()
+ .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, false) });
+
+ RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
+
+ sutProvider.GetDependency()
+ .GetAsync(organizationUser.UserId.Value)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = organizationUser.OrganizationId,
+ OrganizationUserStatus = OrganizationUserStatusType.Revoked,
+ PolicyType = PolicyType.TwoFactorAuthentication
+ }
+ ]));
+
+ var user = new User();
+ user.Email = "test@bitwarden.com";
+ sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id));
+
+ Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant());
+
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .RestoreAsync(Arg.Any(), Arg.Any());
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .PushSyncOrgKeysAsync(Arg.Any());
+ }
+
[Theory, BitAutoData]
public async Task RestoreUser_With2FAPolicyEnabled_WithUser2FAConfigured_Success(
Organization organization,
@@ -235,6 +289,46 @@ public class RestoreOrganizationUserCommandTests
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
}
+ [Theory, BitAutoData]
+ public async Task RestoreUser_WithPolicyRequirementsEnabled_With2FAPolicyEnabled_WithUser2FAConfigured_Success(
+ Organization organization,
+ [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
+ [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
+ SutProvider sutProvider)
+ {
+ organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
+
+ sutProvider.GetDependency()
+ .IsEnabled(FeatureFlagKeys.PolicyRequirements)
+ .Returns(true);
+
+ RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
+
+ sutProvider.GetDependency()
+ .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) });
+ sutProvider.GetDependency()
+ .GetAsync(organizationUser.UserId.Value)
+ .Returns(new RequireTwoFactorPolicyRequirement(
+ [
+ new PolicyDetails
+ {
+ OrganizationId = organizationUser.OrganizationId,
+ OrganizationUserStatus = OrganizationUserStatusType.Revoked,
+ PolicyType = PolicyType.TwoFactorAuthentication
+ }
+ ]));
+
+ await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed);
+ await sutProvider.GetDependency()
+ .Received(1)
+ .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
+ }
+
[Theory, BitAutoData]
public async Task RestoreUser_WithSingleOrgPolicyEnabled_Fails(
Organization organization,
@@ -277,45 +371,6 @@ public class RestoreOrganizationUserCommandTests
.PushSyncOrgKeysAsync(Arg.Any());
}
- [Theory, BitAutoData]
- public async Task RestoreUser_vNext_WithOtherOrganizationSingleOrgPolicyEnabled_Fails(
- Organization organization,
- [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
- [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser,
- SutProvider sutProvider)
- {
- organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
- secondOrganizationUser.UserId = organizationUser.UserId;
- RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
-
- sutProvider.GetDependency()
- .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value)))
- .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) });
-
- sutProvider.GetDependency()
- .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any())
- .Returns(true);
-
- var user = new User { Email = "test@bitwarden.com" };
- sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
-
- var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id));
-
- Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant());
-
- await sutProvider.GetDependency()
- .DidNotReceiveWithAnyArgs()
- .RestoreAsync(Arg.Any(), Arg.Any());
- await sutProvider.GetDependency()
- .DidNotReceiveWithAnyArgs()
- .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any());
- await sutProvider.GetDependency()
- .DidNotReceiveWithAnyArgs()
- .PushSyncOrgKeysAsync(Arg.Any());
- }
-
[Theory, BitAutoData]
public async Task RestoreUser_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails(
Organization organization,
@@ -364,20 +419,42 @@ public class RestoreOrganizationUserCommandTests
}
[Theory, BitAutoData]
- public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails(
+ public async Task RestoreUser_WithPolicyRequirementsEnabled_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails(
Organization organization,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser,
SutProvider sutProvider)
{
- organizationUser.Email = null;
-
+ organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
+ secondOrganizationUser.UserId = organizationUser.UserId;
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
+ sutProvider.GetDependency()
+ .IsEnabled(FeatureFlagKeys.PolicyRequirements)
+ .Returns(true);
+
+ sutProvider.GetDependency()
+ .GetManyByUserAsync(organizationUser.UserId.Value)
+ .Returns(new[] { organizationUser, secondOrganizationUser });
sutProvider.GetDependency()
- .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any())
- .Returns([new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication }
- ]);
+ .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any())
+ .Returns(new[]
+ {
+ new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked }
+ });
+
+ sutProvider.GetDependency()
+ .GetAsync | |