mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
feat(prevent-bad-existing-sso-user): [PM-24579] Prevent Existing Non Confirmed and Accepted SSO Users (#6348)
* feat(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added in logic to block existing sso org users who are not in the confirmed or accepted state. * fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added docs as well as made clear what statuses are permissible. * test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added tests.
This commit is contained in:
committed by
GitHub
parent
df1d7184f8
commit
a71eaeaed2
@@ -136,6 +136,8 @@ EndProject
|
|||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -348,6 +350,10 @@ Global
|
|||||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
|
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -404,6 +410,7 @@ Global
|
|||||||
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||||
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
|
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class AccountController : Controller
|
|||||||
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
|
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
|
||||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||||
private readonly IRegisterUserCommand _registerUserCommand;
|
private readonly IRegisterUserCommand _registerUserCommand;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public AccountController(
|
public AccountController(
|
||||||
IAuthenticationSchemeProvider schemeProvider,
|
IAuthenticationSchemeProvider schemeProvider,
|
||||||
@@ -77,7 +78,8 @@ public class AccountController : Controller
|
|||||||
Core.Services.IEventService eventService,
|
Core.Services.IEventService eventService,
|
||||||
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
|
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
|
||||||
IOrganizationDomainRepository organizationDomainRepository,
|
IOrganizationDomainRepository organizationDomainRepository,
|
||||||
IRegisterUserCommand registerUserCommand)
|
IRegisterUserCommand registerUserCommand,
|
||||||
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_schemeProvider = schemeProvider;
|
_schemeProvider = schemeProvider;
|
||||||
_clientStore = clientStore;
|
_clientStore = clientStore;
|
||||||
@@ -98,10 +100,11 @@ public class AccountController : Controller
|
|||||||
_dataProtector = dataProtector;
|
_dataProtector = dataProtector;
|
||||||
_organizationDomainRepository = organizationDomainRepository;
|
_organizationDomainRepository = organizationDomainRepository;
|
||||||
_registerUserCommand = registerUserCommand;
|
_registerUserCommand = registerUserCommand;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> PreValidate(string domainHint)
|
public async Task<IActionResult> PreValidateAsync(string domainHint)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -160,7 +163,7 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Login(string returnUrl)
|
public async Task<IActionResult> LoginAsync(string returnUrl)
|
||||||
{
|
{
|
||||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||||
|
|
||||||
@@ -235,37 +238,69 @@ public class AccountController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> ExternalCallback()
|
public async Task<IActionResult> ExternalCallback()
|
||||||
{
|
{
|
||||||
|
// Feature flag (PM-24579): Prevent SSO on existing non-compliant users.
|
||||||
|
var preventOrgUserLoginIfStatusInvalid =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers);
|
||||||
|
|
||||||
// Read external identity from the temporary cookie
|
// Read external identity from the temporary cookie
|
||||||
var result = await HttpContext.AuthenticateAsync(
|
var result = await HttpContext.AuthenticateAsync(
|
||||||
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
||||||
if (result?.Succeeded != true)
|
|
||||||
{
|
|
||||||
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debugging
|
if (preventOrgUserLoginIfStatusInvalid)
|
||||||
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
|
{
|
||||||
_logger.LogDebug("External claims: {@claims}", externalClaims);
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (result?.Succeeded != true)
|
||||||
|
{
|
||||||
|
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// See if the user has logged in with this SSO provider before and has already been provisioned.
|
// See if the user has logged in with this SSO provider before and has already been provisioned.
|
||||||
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
|
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
|
||||||
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
||||||
|
|
||||||
|
// We will look these up as required (lazy resolution) to avoid multiple DB hits.
|
||||||
|
Organization organization = null;
|
||||||
|
OrganizationUser orgUser = null;
|
||||||
|
|
||||||
// The user has not authenticated with this SSO provider before.
|
// The user has not authenticated with this SSO provider before.
|
||||||
// They could have an existing Bitwarden account in the User table though.
|
// They could have an existing Bitwarden account in the User table though.
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
|
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
|
||||||
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ?
|
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
|
||||||
result.Properties.Items["user_identifier"] : null;
|
? result.Properties.Items["user_identifier"]
|
||||||
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData);
|
: null;
|
||||||
|
|
||||||
|
var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) =
|
||||||
|
await AutoProvisionUserAsync(
|
||||||
|
provider,
|
||||||
|
providerUserId,
|
||||||
|
claims,
|
||||||
|
userIdentifier,
|
||||||
|
ssoConfigData);
|
||||||
|
|
||||||
|
user = provisionedUser;
|
||||||
|
|
||||||
|
if (preventOrgUserLoginIfStatusInvalid)
|
||||||
|
{
|
||||||
|
organization = foundOrganization;
|
||||||
|
orgUser = foundOrCreatedOrgUser;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
|
if (preventOrgUserLoginIfStatusInvalid)
|
||||||
// Either way, we have associated the SSO login with a Bitwarden user.
|
|
||||||
// We will now sign the Bitwarden user in.
|
|
||||||
if (user != null)
|
|
||||||
{
|
{
|
||||||
|
if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound"));
|
||||||
|
|
||||||
|
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user);
|
||||||
|
|
||||||
// This allows us to collect any additional claims or properties
|
// This allows us to collect any additional claims or properties
|
||||||
// for the specific protocols used and store them in the local auth cookie.
|
// for the specific protocols used and store them in the local auth cookie.
|
||||||
// this is typically used to store data needed for signout from those protocols.
|
// this is typically used to store data needed for signout from those protocols.
|
||||||
@@ -278,12 +313,41 @@ public class AccountController : Controller
|
|||||||
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
|
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
|
||||||
|
|
||||||
// Issue authentication cookie for user
|
// Issue authentication cookie for user
|
||||||
await HttpContext.SignInAsync(new IdentityServerUser(user.Id.ToString())
|
await HttpContext.SignInAsync(
|
||||||
|
new IdentityServerUser(user.Id.ToString())
|
||||||
|
{
|
||||||
|
DisplayName = user.Email,
|
||||||
|
IdentityProvider = provider,
|
||||||
|
AdditionalClaims = additionalLocalClaims.ToArray()
|
||||||
|
}, localSignInProps);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
|
||||||
|
// Either way, we have associated the SSO login with a Bitwarden user.
|
||||||
|
// We will now sign the Bitwarden user in.
|
||||||
|
if (user != null)
|
||||||
{
|
{
|
||||||
DisplayName = user.Email,
|
// This allows us to collect any additional claims or properties
|
||||||
IdentityProvider = provider,
|
// for the specific protocols used and store them in the local auth cookie.
|
||||||
AdditionalClaims = additionalLocalClaims.ToArray()
|
// this is typically used to store data needed for signout from those protocols.
|
||||||
}, localSignInProps);
|
var additionalLocalClaims = new List<Claim>();
|
||||||
|
var localSignInProps = new AuthenticationProperties
|
||||||
|
{
|
||||||
|
IsPersistent = true,
|
||||||
|
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
|
||||||
|
};
|
||||||
|
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
|
||||||
|
|
||||||
|
// Issue authentication cookie for user
|
||||||
|
await HttpContext.SignInAsync(
|
||||||
|
new IdentityServerUser(user.Id.ToString())
|
||||||
|
{
|
||||||
|
DisplayName = user.Email,
|
||||||
|
IdentityProvider = provider,
|
||||||
|
AdditionalClaims = additionalLocalClaims.ToArray()
|
||||||
|
}, localSignInProps);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete temporary cookie used during external authentication
|
// Delete temporary cookie used during external authentication
|
||||||
@@ -310,7 +374,7 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Logout(string logoutId)
|
public async Task<IActionResult> LogoutAsync(string logoutId)
|
||||||
{
|
{
|
||||||
// Build a model so the logged out page knows what to display
|
// Build a model so the logged out page knows what to display
|
||||||
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);
|
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);
|
||||||
@@ -333,6 +397,7 @@ public class AccountController : Controller
|
|||||||
// This triggers a redirect to the external provider for sign-out
|
// This triggers a redirect to the external provider for sign-out
|
||||||
return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme);
|
return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectUri != null)
|
if (redirectUri != null)
|
||||||
{
|
{
|
||||||
return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri });
|
return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri });
|
||||||
@@ -347,7 +412,8 @@ public class AccountController : Controller
|
|||||||
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
|
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
|
||||||
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
|
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)>
|
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims,
|
||||||
|
SsoConfigurationData config)>
|
||||||
FindUserFromExternalProviderAsync(AuthenticateResult result)
|
FindUserFromExternalProviderAsync(AuthenticateResult result)
|
||||||
{
|
{
|
||||||
var provider = result.Properties.Items["scheme"];
|
var provider = result.Properties.Items["scheme"];
|
||||||
@@ -374,9 +440,10 @@ public class AccountController : Controller
|
|||||||
// Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute
|
// Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute
|
||||||
// for the user identifier.
|
// for the user identifier.
|
||||||
static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
|
static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
|
||||||
&& (c.Properties == null
|
&& (c.Properties == null
|
||||||
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat)
|
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat,
|
||||||
|| claimFormat != SamlNameIdFormats.Transient);
|
out var claimFormat)
|
||||||
|
|| claimFormat != SamlNameIdFormats.Transient);
|
||||||
|
|
||||||
// Try to determine the unique id of the external user (issued by the provider)
|
// Try to determine the unique id of the external user (issued by the provider)
|
||||||
// the most common claim type for that are the sub claim and the NameIdentifier
|
// the most common claim type for that are the sub claim and the NameIdentifier
|
||||||
@@ -418,24 +485,20 @@ public class AccountController : Controller
|
|||||||
/// <param name="providerUserId">The external identity provider's user identifier.</param>
|
/// <param name="providerUserId">The external identity provider's user identifier.</param>
|
||||||
/// <param name="claims">The claims from the external IdP.</param>
|
/// <param name="claims">The claims from the external IdP.</param>
|
||||||
/// <param name="userIdentifier">The user identifier used for manual SSO linking.</param>
|
/// <param name="userIdentifier">The user identifier used for manual SSO linking.</param>
|
||||||
/// <param name="config">The SSO configuration for the organization.</param>
|
/// <param name="ssoConfigData">The SSO configuration for the organization.</param>
|
||||||
/// <returns>The User to sign in.</returns>
|
/// <returns>Guaranteed to return the user to sign in as well as the found organization and org user.</returns>
|
||||||
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
|
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
|
||||||
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId,
|
private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)>
|
||||||
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config)
|
AutoProvisionUserAsync(
|
||||||
|
string provider,
|
||||||
|
string providerUserId,
|
||||||
|
IEnumerable<Claim> claims,
|
||||||
|
string userIdentifier,
|
||||||
|
SsoConfigurationData ssoConfigData
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var name = GetName(claims, config.GetAdditionalNameClaimTypes());
|
var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
|
||||||
var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes());
|
var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
|
||||||
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
|
|
||||||
{
|
|
||||||
email = providerUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Guid.TryParse(provider, out var orgId))
|
|
||||||
{
|
|
||||||
// TODO: support non-org (server-wide) SSO in the future?
|
|
||||||
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
|
|
||||||
}
|
|
||||||
|
|
||||||
User existingUser = null;
|
User existingUser = null;
|
||||||
if (string.IsNullOrWhiteSpace(userIdentifier))
|
if (string.IsNullOrWhiteSpace(userIdentifier))
|
||||||
@@ -444,15 +507,19 @@ public class AccountController : Controller
|
|||||||
{
|
{
|
||||||
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
|
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
|
||||||
}
|
}
|
||||||
|
|
||||||
existingUser = await _userRepository.GetByEmailAsync(email);
|
existingUser = await _userRepository.GetByEmailAsync(email);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
existingUser = await GetUserFromManualLinkingData(userIdentifier);
|
existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find the OrganizationUser if it exists.
|
// Try to find the org (we error if we can't find an org)
|
||||||
var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId);
|
var organization = await TryGetOrganizationByProviderAsync(provider);
|
||||||
|
|
||||||
|
// Try to find an org user (null org user possible and valid here)
|
||||||
|
var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email);
|
||||||
|
|
||||||
//----------------------------------------------------
|
//----------------------------------------------------
|
||||||
// Scenario 1: We've found the user in the User table
|
// Scenario 1: We've found the user in the User table
|
||||||
@@ -473,22 +540,22 @@ public class AccountController : Controller
|
|||||||
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(),
|
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
|
||||||
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
|
|
||||||
|
|
||||||
|
|
||||||
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
|
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
|
||||||
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
|
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
|
||||||
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
|
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
|
||||||
// with authentication.
|
// with authentication.
|
||||||
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
|
await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser);
|
||||||
return existingUser;
|
|
||||||
|
return (existingUser, organization, orgUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
|
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
|
||||||
if (orgUser == null && organization.Seats.HasValue)
|
if (orgUser == null && organization.Seats.HasValue)
|
||||||
{
|
{
|
||||||
var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
var occupiedSeats =
|
||||||
|
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||||
var initialSeatCount = organization.Seats.Value;
|
var initialSeatCount = organization.Seats.Value;
|
||||||
var availableSeats = initialSeatCount - occupiedSeats.Total;
|
var availableSeats = initialSeatCount - occupiedSeats.Total;
|
||||||
if (availableSeats < 1)
|
if (availableSeats < 1)
|
||||||
@@ -506,8 +573,10 @@ public class AccountController : Controller
|
|||||||
{
|
{
|
||||||
if (organization.Seats.Value != initialSeatCount)
|
if (organization.Seats.Value != initialSeatCount)
|
||||||
{
|
{
|
||||||
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value);
|
await _organizationService.AdjustSeatsAsync(organization.Id,
|
||||||
|
initialSeatCount - organization.Seats.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(e, "SSO auto provisioning failed");
|
_logger.LogInformation(e, "SSO auto provisioning failed");
|
||||||
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
|
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
|
||||||
}
|
}
|
||||||
@@ -519,7 +588,8 @@ public class AccountController : Controller
|
|||||||
var emailDomain = CoreHelpers.GetEmailDomain(email);
|
var emailDomain = CoreHelpers.GetEmailDomain(email);
|
||||||
if (!string.IsNullOrWhiteSpace(emailDomain))
|
if (!string.IsNullOrWhiteSpace(emailDomain))
|
||||||
{
|
{
|
||||||
var organizationDomain = await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(orgId, emailDomain);
|
var organizationDomain =
|
||||||
|
await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(organization.Id, emailDomain);
|
||||||
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
|
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +607,7 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||||
var twoFactorPolicy =
|
var twoFactorPolicy =
|
||||||
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.TwoFactorAuthentication);
|
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
||||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||||
{
|
{
|
||||||
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||||
@@ -560,13 +630,14 @@ public class AccountController : Controller
|
|||||||
{
|
{
|
||||||
orgUser = new OrganizationUser
|
orgUser = new OrganizationUser
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
OrganizationId = organization.Id,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Type = OrganizationUserType.User,
|
Type = OrganizationUserType.User,
|
||||||
Status = OrganizationUserStatusType.Invited
|
Status = OrganizationUserStatusType.Invited
|
||||||
};
|
};
|
||||||
await _organizationUserRepository.CreateAsync(orgUser);
|
await _organizationUserRepository.CreateAsync(orgUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------------------
|
//-----------------------------------------------------------------
|
||||||
// Scenario 3: There is already an existing OrganizationUser
|
// Scenario 3: There is already an existing OrganizationUser
|
||||||
// That was established through an invitation. We just need to
|
// That was established through an invitation. We just need to
|
||||||
@@ -579,12 +650,47 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the SsoUser record to link the user to the SSO provider.
|
// Create the SsoUser record to link the user to the SSO provider.
|
||||||
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser);
|
await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser);
|
||||||
|
|
||||||
return user;
|
return (user, organization, orgUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<User> GetUserFromManualLinkingData(string userIdentifier)
|
/// <summary>
|
||||||
|
/// Validates an organization user is allowed to log in via SSO and blocks invalid statuses.
|
||||||
|
/// Lazily resolves the organization and organization user if not provided.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organization">The target organization; if null, resolved from provider.</param>
|
||||||
|
/// <param name="provider">The SSO scheme provider value (organization id as a GUID string).</param>
|
||||||
|
/// <param name="orgUser">The organization-user record; if null, looked up by user/org or user email for invited users.</param>
|
||||||
|
/// <param name="user">The user attempting to sign in (existing or newly provisioned).</param>
|
||||||
|
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
|
||||||
|
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
|
||||||
|
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
|
||||||
|
Organization organization,
|
||||||
|
string provider,
|
||||||
|
OrganizationUser orgUser,
|
||||||
|
User user)
|
||||||
|
{
|
||||||
|
// Lazily get organization if not already known
|
||||||
|
organization ??= await TryGetOrganizationByProviderAsync(provider);
|
||||||
|
|
||||||
|
// Lazily get the org user if not already known
|
||||||
|
orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail(
|
||||||
|
user,
|
||||||
|
organization.Id,
|
||||||
|
user.Email);
|
||||||
|
|
||||||
|
if (orgUser != null)
|
||||||
|
{
|
||||||
|
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new Exception(_i18nService.T("CouldNotFindOrganizationUser", user.Id, organization.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<User> GetUserFromManualLinkingDataAsync(string userIdentifier)
|
||||||
{
|
{
|
||||||
User user = null;
|
User user = null;
|
||||||
var split = userIdentifier.Split(",");
|
var split = userIdentifier.Split(",");
|
||||||
@@ -592,6 +698,7 @@ public class AccountController : Controller
|
|||||||
{
|
{
|
||||||
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
|
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = split[0];
|
var userId = split[0];
|
||||||
var token = split[1];
|
var token = split[1];
|
||||||
|
|
||||||
@@ -611,38 +718,73 @@ public class AccountController : Controller
|
|||||||
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
|
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId)
|
/// <summary>
|
||||||
|
/// Tries to get the organization by the provider which is org id for us as we use the scheme
|
||||||
|
/// to identify organizations - not identity providers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">Org id string from SSO scheme property</param>
|
||||||
|
/// <exception cref="Exception">Errors if the provider string is not a valid org id guid or if the org cannot be found by the id.</exception>
|
||||||
|
private async Task<Organization> TryGetOrganizationByProviderAsync(string provider)
|
||||||
{
|
{
|
||||||
OrganizationUser orgUser = null;
|
if (!Guid.TryParse(provider, out var organizationId))
|
||||||
var organization = await _organizationRepository.GetByIdAsync(orgId);
|
{
|
||||||
|
// TODO: support non-org (server-wide) SSO in the future?
|
||||||
|
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
if (organization == null)
|
if (organization == null)
|
||||||
{
|
{
|
||||||
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
|
throw new Exception(_i18nService.T("CouldNotFindOrganization", organizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to get an <see cref="OrganizationUser"/> for a given organization
|
||||||
|
/// by first checking for an existing user relationship, and if none is found,
|
||||||
|
/// by looking up an invited user via their email address.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The existing user entity to be looked up in OrganizationUsers table.</param>
|
||||||
|
/// <param name="organizationId">Organization id from the provider data.</param>
|
||||||
|
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
|
||||||
|
/// table yet.</param>
|
||||||
|
private async Task<OrganizationUser> TryGetOrganizationUserByUserAndOrgOrEmail(
|
||||||
|
User user,
|
||||||
|
Guid organizationId,
|
||||||
|
string email)
|
||||||
|
{
|
||||||
|
OrganizationUser orgUser = null;
|
||||||
|
|
||||||
// Try to find OrgUser via existing User Id.
|
// Try to find OrgUser via existing User Id.
|
||||||
// This covers any OrganizationUser state after they have accepted an invite.
|
// This covers any OrganizationUser state after they have accepted an invite.
|
||||||
if (existingUser != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
|
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id);
|
||||||
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
|
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no Org User found by Existing User Id - search all the organization's users via email.
|
// If no Org User found by Existing User Id - search all the organization's users via email.
|
||||||
// This covers users who are Invited but haven't accepted their invite yet.
|
// This covers users who are Invited but haven't accepted their invite yet.
|
||||||
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
|
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
|
||||||
|
|
||||||
return (organization, orgUser);
|
return orgUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureOrgUserStatusAllowed(
|
private void EnsureAcceptedOrConfirmedOrgUserStatus(
|
||||||
OrganizationUserStatusType status,
|
OrganizationUserStatusType status,
|
||||||
string organizationDisplayName,
|
string organizationDisplayName)
|
||||||
params OrganizationUserStatusType[] allowedStatuses)
|
|
||||||
{
|
{
|
||||||
|
// The only permissible org user statuses allowed.
|
||||||
|
OrganizationUserStatusType[] allowedStatuses =
|
||||||
|
[OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed];
|
||||||
|
|
||||||
// if this status is one of the allowed ones, just return
|
// if this status is one of the allowed ones, just return
|
||||||
if (allowedStatuses.Contains(status))
|
if (allowedStatuses.Contains(status))
|
||||||
{
|
{
|
||||||
@@ -667,7 +809,6 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
|
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
|
||||||
{
|
{
|
||||||
Response.StatusCode = ex == null ? 400 : 500;
|
Response.StatusCode = ex == null ? 400 : 500;
|
||||||
@@ -679,13 +820,13 @@ public class AccountController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
private string TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
||||||
{
|
{
|
||||||
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
|
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
|
||||||
|
|
||||||
var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
|
var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
|
||||||
filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email,
|
filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email,
|
||||||
SamlClaimTypes.Email, "mail", "emailaddress");
|
SamlClaimTypes.Email, "mail", "emailaddress");
|
||||||
if (!string.IsNullOrWhiteSpace(email))
|
if (!string.IsNullOrWhiteSpace(email))
|
||||||
{
|
{
|
||||||
return email;
|
return email;
|
||||||
@@ -706,8 +847,8 @@ public class AccountController : Controller
|
|||||||
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
|
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
|
||||||
|
|
||||||
var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
|
var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
|
||||||
filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name,
|
filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name,
|
||||||
SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn");
|
SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn");
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
{
|
{
|
||||||
return name;
|
return name;
|
||||||
@@ -725,7 +866,8 @@ public class AccountController : Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CreateSsoUserRecord(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser)
|
private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
|
||||||
|
OrganizationUser orgUser)
|
||||||
{
|
{
|
||||||
// Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale
|
// Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale
|
||||||
var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId);
|
var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId);
|
||||||
@@ -740,12 +882,7 @@ public class AccountController : Controller
|
|||||||
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin);
|
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
var ssoUser = new SsoUser
|
var ssoUser = new SsoUser { ExternalId = providerUserId, UserId = userId, OrganizationId = orgId, };
|
||||||
{
|
|
||||||
ExternalId = providerUserId,
|
|
||||||
UserId = userId,
|
|
||||||
OrganizationId = orgId,
|
|
||||||
};
|
|
||||||
await _ssoUserRepository.CreateAsync(ssoUser);
|
await _ssoUserRepository.CreateAsync(ssoUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,18 +906,6 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetProviderAsync(string returnUrl)
|
|
||||||
{
|
|
||||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
|
||||||
if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
|
|
||||||
{
|
|
||||||
return context.IdP;
|
|
||||||
}
|
|
||||||
var schemes = await _schemeProvider.GetAllSchemesAsync();
|
|
||||||
var providers = schemes.Select(x => x.Name).ToList();
|
|
||||||
return providers.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
|
private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
|
||||||
{
|
{
|
||||||
// Get context information (client name, post logout redirect URI and iframe for federated signout)
|
// Get context information (client name, post logout redirect URI and iframe for federated signout)
|
||||||
@@ -812,9 +937,29 @@ public class AccountController : Controller
|
|||||||
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
|
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
|
||||||
|
* the claims email extraction returns null.
|
||||||
|
*/
|
||||||
|
private string TryGetEmailAddress(
|
||||||
|
IEnumerable<Claim> claims,
|
||||||
|
SsoConfigurationData config,
|
||||||
|
string providerUserId)
|
||||||
|
{
|
||||||
|
var email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes());
|
||||||
|
|
||||||
|
// If email isn't populated from claims and providerUserId has @, assume it is the email.
|
||||||
|
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
|
||||||
|
{
|
||||||
|
email = providerUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsNativeClient(DIM.AuthorizationRequest context)
|
public bool IsNativeClient(DIM.AuthorizationRequest context)
|
||||||
{
|
{
|
||||||
return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)
|
return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)
|
||||||
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
|
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1029
bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs
Normal file
1029
bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs
Normal file
File diff suppressed because it is too large
Load Diff
35
bitwarden_license/test/SSO.Test/SSO.Test.csproj
Normal file
35
bitwarden_license/test/SSO.Test/SSO.Test.csproj
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||||
|
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -151,6 +151,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
|
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
|
||||||
public const string Otp6Digits = "pm-18612-otp-6-digits";
|
public const string Otp6Digits = "pm-18612-otp-6-digits";
|
||||||
public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
|
public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
|
||||||
|
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
|
||||||
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
|
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
|
||||||
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
|
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
|
||||||
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
|
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
|
||||||
|
|||||||
@@ -508,9 +508,15 @@
|
|||||||
<data name="UserIdAndTokenMismatch" xml:space="preserve">
|
<data name="UserIdAndTokenMismatch" xml:space="preserve">
|
||||||
<value>Supplied userId and token did not match.</value>
|
<value>Supplied userId and token did not match.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="UserShouldBeFound" xml:space="preserve">
|
||||||
|
<value>User should have been defined by this point.</value>
|
||||||
|
</data>
|
||||||
<data name="CouldNotFindOrganization" xml:space="preserve">
|
<data name="CouldNotFindOrganization" xml:space="preserve">
|
||||||
<value>Could not find organization for '{0}'</value>
|
<value>Could not find organization for '{0}'</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="CouldNotFindOrganizationUser" xml:space="preserve">
|
||||||
|
<value>Could not find organization user for user '{0}' organization '{1}'</value>
|
||||||
|
</data>
|
||||||
<data name="NoSeatsAvailable" xml:space="preserve">
|
<data name="NoSeatsAvailable" xml:space="preserve">
|
||||||
<value>No seats available for organization, '{0}'</value>
|
<value>No seats available for organization, '{0}'</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
Reference in New Issue
Block a user