mirror of
https://github.com/bitwarden/server
synced 2025-12-30 23:23:37 +00:00
Revert filescoped (#2227)
* Revert "Add git blame entry (#2226)" This reverts commit239286737d. * Revert "Turn on file scoped namespaces (#2225)" This reverts commit34fb4cca2a.
This commit is contained in:
@@ -13,496 +13,497 @@ using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace Bit.Commercial.Core.Services;
|
||||
|
||||
public class ProviderService : IProviderService
|
||||
namespace Bit.Commercial.Core.Services
|
||||
{
|
||||
public static PlanType[] ProviderDisllowedOrganizationTypes = new[] { PlanType.Free, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 };
|
||||
|
||||
private readonly IDataProtector _dataProtector;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
IUserService userService, IOrganizationService organizationService, IMailService mailService,
|
||||
IDataProtectionProvider dataProtectionProvider, IEventService eventService,
|
||||
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext)
|
||||
public class ProviderService : IProviderService
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_userRepository = userRepository;
|
||||
_userService = userService;
|
||||
_organizationService = organizationService;
|
||||
_mailService = mailService;
|
||||
_eventService = eventService;
|
||||
_globalSettings = globalSettings;
|
||||
_dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
public static PlanType[] ProviderDisllowedOrganizationTypes = new[] { PlanType.Free, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 };
|
||||
|
||||
public async Task CreateAsync(string ownerEmail)
|
||||
{
|
||||
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
|
||||
if (owner == null)
|
||||
private readonly IDataProtector _dataProtector;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
IUserService userService, IOrganizationService organizationService, IMailService mailService,
|
||||
IDataProtectionProvider dataProtectionProvider, IEventService eventService,
|
||||
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_userRepository = userRepository;
|
||||
_userService = userService;
|
||||
_organizationService = organizationService;
|
||||
_mailService = mailService;
|
||||
_eventService = eventService;
|
||||
_globalSettings = globalSettings;
|
||||
_dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
var provider = new Provider
|
||||
public async Task CreateAsync(string ownerEmail)
|
||||
{
|
||||
Status = ProviderStatusType.Pending,
|
||||
Enabled = true,
|
||||
UseEvents = true,
|
||||
};
|
||||
await _providerRepository.CreateAsync(provider);
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = owner.Id,
|
||||
Type = ProviderUserType.ProviderAdmin,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
};
|
||||
await _providerUserRepository.CreateAsync(providerUser);
|
||||
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
{
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
if (provider.Status != ProviderStatusType.Pending)
|
||||
{
|
||||
throw new BadRequestException("Provider is already setup.");
|
||||
}
|
||||
|
||||
if (!CoreHelpers.TokenIsValid("ProviderSetupInvite", _dataProtector, token, owner.Email, provider.Id,
|
||||
_globalSettings.OrganizationInviteExpirationHours))
|
||||
{
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
var providerUser = await _providerUserRepository.GetByProviderUserAsync(provider.Id, ownerUserId);
|
||||
if (!(providerUser is { Type: ProviderUserType.ProviderAdmin }))
|
||||
{
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
|
||||
providerUser.Key = key;
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Provider provider, bool updateBilling = false)
|
||||
{
|
||||
if (provider.Id == default)
|
||||
{
|
||||
throw new ArgumentException("Cannot create provider this way.");
|
||||
}
|
||||
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
}
|
||||
|
||||
public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
|
||||
{
|
||||
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid permissions.");
|
||||
}
|
||||
|
||||
var emails = invite?.UserIdentifiers;
|
||||
var invitingUser = await _providerUserRepository.GetByProviderUserAsync(invite.ProviderId, invite.InvitingUserId);
|
||||
|
||||
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
|
||||
if (provider == null || emails == null || !emails.Any())
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var providerUsers = new List<ProviderUser>();
|
||||
foreach (var email in emails)
|
||||
{
|
||||
// Make sure user is not already invited
|
||||
var existingProviderUserCount =
|
||||
await _providerUserRepository.GetCountByProviderAsync(invite.ProviderId, email, false);
|
||||
if (existingProviderUserCount > 0)
|
||||
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
|
||||
if (owner == null)
|
||||
{
|
||||
continue;
|
||||
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
|
||||
}
|
||||
|
||||
var provider = new Provider
|
||||
{
|
||||
Status = ProviderStatusType.Pending,
|
||||
Enabled = true,
|
||||
UseEvents = true,
|
||||
};
|
||||
await _providerRepository.CreateAsync(provider);
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
ProviderId = invite.ProviderId,
|
||||
UserId = null,
|
||||
Email = email.ToLowerInvariant(),
|
||||
Key = null,
|
||||
Type = invite.Type,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
ProviderId = provider.Id,
|
||||
UserId = owner.Id,
|
||||
Type = ProviderUserType.ProviderAdmin,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
};
|
||||
await _providerUserRepository.CreateAsync(providerUser);
|
||||
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
{
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
if (provider.Status != ProviderStatusType.Pending)
|
||||
{
|
||||
throw new BadRequestException("Provider is already setup.");
|
||||
}
|
||||
|
||||
if (!CoreHelpers.TokenIsValid("ProviderSetupInvite", _dataProtector, token, owner.Email, provider.Id,
|
||||
_globalSettings.OrganizationInviteExpirationHours))
|
||||
{
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
var providerUser = await _providerUserRepository.GetByProviderUserAsync(provider.Id, ownerUserId);
|
||||
if (!(providerUser is { Type: ProviderUserType.ProviderAdmin }))
|
||||
{
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
|
||||
providerUser.Key = key;
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Provider provider, bool updateBilling = false)
|
||||
{
|
||||
if (provider.Id == default)
|
||||
{
|
||||
throw new ArgumentException("Cannot create provider this way.");
|
||||
}
|
||||
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
}
|
||||
|
||||
public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
|
||||
{
|
||||
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid permissions.");
|
||||
}
|
||||
|
||||
var emails = invite?.UserIdentifiers;
|
||||
var invitingUser = await _providerUserRepository.GetByProviderUserAsync(invite.ProviderId, invite.InvitingUserId);
|
||||
|
||||
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
|
||||
if (provider == null || emails == null || !emails.Any())
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var providerUsers = new List<ProviderUser>();
|
||||
foreach (var email in emails)
|
||||
{
|
||||
// Make sure user is not already invited
|
||||
var existingProviderUserCount =
|
||||
await _providerUserRepository.GetCountByProviderAsync(invite.ProviderId, email, false);
|
||||
if (existingProviderUserCount > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
ProviderId = invite.ProviderId,
|
||||
UserId = null,
|
||||
Email = email.ToLowerInvariant(),
|
||||
Key = null,
|
||||
Type = invite.Type,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _providerUserRepository.CreateAsync(providerUser);
|
||||
|
||||
await SendInviteAsync(providerUser, provider);
|
||||
providerUsers.Add(providerUser);
|
||||
}
|
||||
|
||||
await _eventService.LogProviderUsersEventAsync(providerUsers.Select(pu => (pu, EventType.ProviderUser_Invited, null as DateTime?)));
|
||||
|
||||
return providerUsers;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<ProviderUser, string>>> ResendInvitesAsync(ProviderUserInvite<Guid> invite)
|
||||
{
|
||||
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
|
||||
{
|
||||
throw new BadRequestException("Invalid permissions.");
|
||||
}
|
||||
|
||||
var providerUsers = await _providerUserRepository.GetManyAsync(invite.UserIdentifiers);
|
||||
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
|
||||
|
||||
var result = new List<Tuple<ProviderUser, string>>();
|
||||
foreach (var providerUser in providerUsers)
|
||||
{
|
||||
if (providerUser.Status != ProviderUserStatusType.Invited || providerUser.ProviderId != invite.ProviderId)
|
||||
{
|
||||
result.Add(Tuple.Create(providerUser, "User invalid."));
|
||||
continue;
|
||||
}
|
||||
|
||||
await SendInviteAsync(providerUser, provider);
|
||||
result.Add(Tuple.Create(providerUser, ""));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ProviderUser> AcceptUserAsync(Guid providerUserId, User user, string token)
|
||||
{
|
||||
var providerUser = await _providerUserRepository.GetByIdAsync(providerUserId);
|
||||
if (providerUser == null)
|
||||
{
|
||||
throw new BadRequestException("User invalid.");
|
||||
}
|
||||
|
||||
if (providerUser.Status != ProviderUserStatusType.Invited)
|
||||
{
|
||||
throw new BadRequestException("Already accepted.");
|
||||
}
|
||||
|
||||
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _dataProtector, token, user.Email, providerUser.Id,
|
||||
_globalSettings.OrganizationInviteExpirationHours))
|
||||
{
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUser.Email) ||
|
||||
!providerUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
throw new BadRequestException("User email does not match invite.");
|
||||
}
|
||||
|
||||
providerUser.Status = ProviderUserStatusType.Accepted;
|
||||
providerUser.UserId = user.Id;
|
||||
providerUser.Email = null;
|
||||
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
|
||||
return providerUser;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<ProviderUser, string>>> ConfirmUsersAsync(Guid providerId, Dictionary<Guid, string> keys,
|
||||
Guid confirmingUserId)
|
||||
{
|
||||
var providerUsers = await _providerUserRepository.GetManyAsync(keys.Keys);
|
||||
var validProviderUsers = providerUsers
|
||||
.Where(u => u.UserId != null)
|
||||
.ToList();
|
||||
|
||||
if (!validProviderUsers.Any())
|
||||
{
|
||||
return new List<Tuple<ProviderUser, string>>();
|
||||
}
|
||||
|
||||
var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList();
|
||||
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
var users = await _userRepository.GetManyAsync(validOrganizationUserIds);
|
||||
|
||||
var keyedFilteredUsers = validProviderUsers.ToDictionary(u => u.UserId.Value, u => u);
|
||||
|
||||
var result = new List<Tuple<ProviderUser, string>>();
|
||||
var events = new List<(ProviderUser, EventType, DateTime?)>();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (!keyedFilteredUsers.ContainsKey(user.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var providerUser = keyedFilteredUsers[user.Id];
|
||||
try
|
||||
{
|
||||
if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId)
|
||||
{
|
||||
throw new BadRequestException("Invalid user.");
|
||||
}
|
||||
|
||||
providerUser.Status = ProviderUserStatusType.Confirmed;
|
||||
providerUser.Key = keys[providerUser.Id];
|
||||
providerUser.Email = null;
|
||||
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
events.Add((providerUser, EventType.ProviderUser_Confirmed, null));
|
||||
await _mailService.SendProviderConfirmedEmailAsync(provider.Name, user.Email);
|
||||
result.Add(Tuple.Create(providerUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(providerUser, e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
await _eventService.LogProviderUsersEventAsync(events);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task SaveUserAsync(ProviderUser user, Guid savingUserId)
|
||||
{
|
||||
if (user.Id.Equals(default))
|
||||
{
|
||||
throw new BadRequestException("Invite the user first.");
|
||||
}
|
||||
|
||||
if (user.Type != ProviderUserType.ProviderAdmin &&
|
||||
!await HasConfirmedProviderAdminExceptAsync(user.ProviderId, new[] { user.Id }))
|
||||
{
|
||||
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
|
||||
}
|
||||
|
||||
await _providerUserRepository.ReplaceAsync(user);
|
||||
await _eventService.LogProviderUserEventAsync(user, EventType.ProviderUser_Updated);
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId,
|
||||
IEnumerable<Guid> providerUserIds, Guid deletingUserId)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var providerUsers = await _providerUserRepository.GetManyAsync(providerUserIds);
|
||||
var users = await _userRepository.GetManyAsync(providerUsers.Where(pu => pu.UserId.HasValue)
|
||||
.Select(pu => pu.UserId.Value));
|
||||
var keyedUsers = users.ToDictionary(u => u.Id);
|
||||
|
||||
if (!await HasConfirmedProviderAdminExceptAsync(providerId, providerUserIds))
|
||||
{
|
||||
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
|
||||
}
|
||||
|
||||
var result = new List<Tuple<ProviderUser, string>>();
|
||||
var deletedUserIds = new List<Guid>();
|
||||
var events = new List<(ProviderUser, EventType, DateTime?)>();
|
||||
|
||||
foreach (var providerUser in providerUsers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (providerUser.ProviderId != providerId)
|
||||
{
|
||||
throw new BadRequestException("Invalid user.");
|
||||
}
|
||||
if (providerUser.UserId == deletingUserId)
|
||||
{
|
||||
throw new BadRequestException("You cannot remove yourself.");
|
||||
}
|
||||
|
||||
events.Add((providerUser, EventType.ProviderUser_Removed, null));
|
||||
|
||||
var user = keyedUsers.GetValueOrDefault(providerUser.UserId.GetValueOrDefault());
|
||||
var email = user == null ? providerUser.Email : user.Email;
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
await _mailService.SendProviderUserRemoved(provider.Name, email);
|
||||
}
|
||||
|
||||
result.Add(Tuple.Create(providerUser, ""));
|
||||
deletedUserIds.Add(providerUser.Id);
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(providerUser, e.Message));
|
||||
}
|
||||
|
||||
await _providerUserRepository.DeleteManyAsync(deletedUserIds);
|
||||
}
|
||||
|
||||
await _eventService.LogProviderUsersEventAsync(events);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key)
|
||||
{
|
||||
var po = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
|
||||
if (po != null)
|
||||
{
|
||||
throw new BadRequestException("Organization already belongs to a provider.");
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
ThrowOnInvalidPlanType(organization.PlanType);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
ProviderId = providerId,
|
||||
OrganizationId = organizationId,
|
||||
Key = key,
|
||||
};
|
||||
|
||||
await _providerUserRepository.CreateAsync(providerUser);
|
||||
|
||||
await SendInviteAsync(providerUser, provider);
|
||||
providerUsers.Add(providerUser);
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added);
|
||||
}
|
||||
|
||||
await _eventService.LogProviderUsersEventAsync(providerUsers.Select(pu => (pu, EventType.ProviderUser_Invited, null as DateTime?)));
|
||||
|
||||
return providerUsers;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<ProviderUser, string>>> ResendInvitesAsync(ProviderUserInvite<Guid> invite)
|
||||
{
|
||||
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
|
||||
public async Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId,
|
||||
OrganizationSignup organizationSignup, string clientOwnerEmail, User user)
|
||||
{
|
||||
throw new BadRequestException("Invalid permissions.");
|
||||
}
|
||||
ThrowOnInvalidPlanType(organizationSignup.Plan);
|
||||
|
||||
var providerUsers = await _providerUserRepository.GetManyAsync(invite.UserIdentifiers);
|
||||
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
|
||||
var (organization, _) = await _organizationService.SignUpAsync(organizationSignup, true);
|
||||
|
||||
var result = new List<Tuple<ProviderUser, string>>();
|
||||
foreach (var providerUser in providerUsers)
|
||||
{
|
||||
if (providerUser.Status != ProviderUserStatusType.Invited || providerUser.ProviderId != invite.ProviderId)
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
result.Add(Tuple.Create(providerUser, "User invalid."));
|
||||
continue;
|
||||
}
|
||||
ProviderId = providerId,
|
||||
OrganizationId = organization.Id,
|
||||
Key = organizationSignup.OwnerKey,
|
||||
};
|
||||
|
||||
await SendInviteAsync(providerUser, provider);
|
||||
result.Add(Tuple.Create(providerUser, ""));
|
||||
}
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ProviderUser> AcceptUserAsync(Guid providerUserId, User user, string token)
|
||||
{
|
||||
var providerUser = await _providerUserRepository.GetByIdAsync(providerUserId);
|
||||
if (providerUser == null)
|
||||
{
|
||||
throw new BadRequestException("User invalid.");
|
||||
}
|
||||
|
||||
if (providerUser.Status != ProviderUserStatusType.Invited)
|
||||
{
|
||||
throw new BadRequestException("Already accepted.");
|
||||
}
|
||||
|
||||
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _dataProtector, token, user.Email, providerUser.Id,
|
||||
_globalSettings.OrganizationInviteExpirationHours))
|
||||
{
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUser.Email) ||
|
||||
!providerUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
throw new BadRequestException("User email does not match invite.");
|
||||
}
|
||||
|
||||
providerUser.Status = ProviderUserStatusType.Accepted;
|
||||
providerUser.UserId = user.Id;
|
||||
providerUser.Email = null;
|
||||
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
|
||||
return providerUser;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<ProviderUser, string>>> ConfirmUsersAsync(Guid providerId, Dictionary<Guid, string> keys,
|
||||
Guid confirmingUserId)
|
||||
{
|
||||
var providerUsers = await _providerUserRepository.GetManyAsync(keys.Keys);
|
||||
var validProviderUsers = providerUsers
|
||||
.Where(u => u.UserId != null)
|
||||
.ToList();
|
||||
|
||||
if (!validProviderUsers.Any())
|
||||
{
|
||||
return new List<Tuple<ProviderUser, string>>();
|
||||
}
|
||||
|
||||
var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList();
|
||||
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
var users = await _userRepository.GetManyAsync(validOrganizationUserIds);
|
||||
|
||||
var keyedFilteredUsers = validProviderUsers.ToDictionary(u => u.UserId.Value, u => u);
|
||||
|
||||
var result = new List<Tuple<ProviderUser, string>>();
|
||||
var events = new List<(ProviderUser, EventType, DateTime?)>();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (!keyedFilteredUsers.ContainsKey(user.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var providerUser = keyedFilteredUsers[user.Id];
|
||||
try
|
||||
{
|
||||
if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId)
|
||||
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
|
||||
new (OrganizationUserInvite, string)[]
|
||||
{
|
||||
throw new BadRequestException("Invalid user.");
|
||||
}
|
||||
(
|
||||
new OrganizationUserInvite
|
||||
{
|
||||
Emails = new[] { clientOwnerEmail },
|
||||
AccessAll = true,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Permissions = null,
|
||||
Collections = Array.Empty<SelectionReadOnly>(),
|
||||
},
|
||||
null
|
||||
)
|
||||
});
|
||||
|
||||
providerUser.Status = ProviderUserStatusType.Confirmed;
|
||||
providerUser.Key = keys[providerUser.Id];
|
||||
providerUser.Email = null;
|
||||
return providerOrganization;
|
||||
}
|
||||
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
events.Add((providerUser, EventType.ProviderUser_Confirmed, null));
|
||||
await _mailService.SendProviderConfirmedEmailAsync(provider.Name, user.Email);
|
||||
result.Add(Tuple.Create(providerUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
public async Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId)
|
||||
{
|
||||
var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(providerOrganizationId);
|
||||
if (providerOrganization == null || providerOrganization.ProviderId != providerId)
|
||||
{
|
||||
result.Add(Tuple.Create(providerUser, e.Message));
|
||||
throw new BadRequestException("Invalid organization.");
|
||||
}
|
||||
|
||||
if (!await _organizationService.HasConfirmedOwnersExceptAsync(providerOrganization.OrganizationId, new Guid[] { }, includeProvider: false))
|
||||
{
|
||||
throw new BadRequestException("Organization needs to have at least one confirmed owner.");
|
||||
}
|
||||
|
||||
await _providerOrganizationRepository.DeleteAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
|
||||
public async Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
var owner = await _userRepository.GetByIdAsync(ownerId);
|
||||
if (owner == null)
|
||||
{
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
||||
}
|
||||
|
||||
private async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail)
|
||||
{
|
||||
var token = _dataProtector.Protect($"ProviderSetupInvite {provider.Id} {ownerEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
await _mailService.SendProviderSetupInviteEmailAsync(provider, token, ownerEmail);
|
||||
}
|
||||
|
||||
public async Task LogProviderAccessToOrganizationAsync(Guid organizationId)
|
||||
{
|
||||
if (organizationId == default)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var providerOrganization = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
if (providerOrganization != null)
|
||||
{
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_VaultAccessed);
|
||||
}
|
||||
if (organization != null)
|
||||
{
|
||||
await _eventService.LogOrganizationEventAsync(organization, EventType.Organization_VaultAccessed);
|
||||
}
|
||||
}
|
||||
|
||||
await _eventService.LogProviderUsersEventAsync(events);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task SaveUserAsync(ProviderUser user, Guid savingUserId)
|
||||
{
|
||||
if (user.Id.Equals(default))
|
||||
private async Task SendInviteAsync(ProviderUser providerUser, Provider provider)
|
||||
{
|
||||
throw new BadRequestException("Invite the user first.");
|
||||
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
|
||||
var token = _dataProtector.Protect(
|
||||
$"ProviderUserInvite {providerUser.Id} {providerUser.Email} {nowMillis}");
|
||||
await _mailService.SendProviderInviteEmailAsync(provider.Name, providerUser, token, providerUser.Email);
|
||||
}
|
||||
|
||||
if (user.Type != ProviderUserType.ProviderAdmin &&
|
||||
!await HasConfirmedProviderAdminExceptAsync(user.ProviderId, new[] { user.Id }))
|
||||
private async Task<bool> HasConfirmedProviderAdminExceptAsync(Guid providerId, IEnumerable<Guid> providerUserIds)
|
||||
{
|
||||
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
|
||||
var providerAdmins = await _providerUserRepository.GetManyByProviderAsync(providerId,
|
||||
ProviderUserType.ProviderAdmin);
|
||||
var confirmedOwners = providerAdmins.Where(o => o.Status == ProviderUserStatusType.Confirmed);
|
||||
var confirmedOwnersIds = confirmedOwners.Select(u => u.Id);
|
||||
return confirmedOwnersIds.Except(providerUserIds).Any();
|
||||
}
|
||||
|
||||
await _providerUserRepository.ReplaceAsync(user);
|
||||
await _eventService.LogProviderUserEventAsync(user, EventType.ProviderUser_Updated);
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId,
|
||||
IEnumerable<Guid> providerUserIds, Guid deletingUserId)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
private void ThrowOnInvalidPlanType(PlanType requestedType)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var providerUsers = await _providerUserRepository.GetManyAsync(providerUserIds);
|
||||
var users = await _userRepository.GetManyAsync(providerUsers.Where(pu => pu.UserId.HasValue)
|
||||
.Select(pu => pu.UserId.Value));
|
||||
var keyedUsers = users.ToDictionary(u => u.Id);
|
||||
|
||||
if (!await HasConfirmedProviderAdminExceptAsync(providerId, providerUserIds))
|
||||
{
|
||||
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
|
||||
}
|
||||
|
||||
var result = new List<Tuple<ProviderUser, string>>();
|
||||
var deletedUserIds = new List<Guid>();
|
||||
var events = new List<(ProviderUser, EventType, DateTime?)>();
|
||||
|
||||
foreach (var providerUser in providerUsers)
|
||||
{
|
||||
try
|
||||
if (ProviderDisllowedOrganizationTypes.Contains(requestedType))
|
||||
{
|
||||
if (providerUser.ProviderId != providerId)
|
||||
{
|
||||
throw new BadRequestException("Invalid user.");
|
||||
}
|
||||
if (providerUser.UserId == deletingUserId)
|
||||
{
|
||||
throw new BadRequestException("You cannot remove yourself.");
|
||||
}
|
||||
|
||||
events.Add((providerUser, EventType.ProviderUser_Removed, null));
|
||||
|
||||
var user = keyedUsers.GetValueOrDefault(providerUser.UserId.GetValueOrDefault());
|
||||
var email = user == null ? providerUser.Email : user.Email;
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
await _mailService.SendProviderUserRemoved(provider.Name, email);
|
||||
}
|
||||
|
||||
result.Add(Tuple.Create(providerUser, ""));
|
||||
deletedUserIds.Add(providerUser.Id);
|
||||
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(providerUser, e.Message));
|
||||
}
|
||||
|
||||
await _providerUserRepository.DeleteManyAsync(deletedUserIds);
|
||||
}
|
||||
|
||||
await _eventService.LogProviderUsersEventAsync(events);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key)
|
||||
{
|
||||
var po = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
|
||||
if (po != null)
|
||||
{
|
||||
throw new BadRequestException("Organization already belongs to a provider.");
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
ThrowOnInvalidPlanType(organization.PlanType);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
ProviderId = providerId,
|
||||
OrganizationId = organizationId,
|
||||
Key = key,
|
||||
};
|
||||
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added);
|
||||
}
|
||||
|
||||
public async Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId,
|
||||
OrganizationSignup organizationSignup, string clientOwnerEmail, User user)
|
||||
{
|
||||
ThrowOnInvalidPlanType(organizationSignup.Plan);
|
||||
|
||||
var (organization, _) = await _organizationService.SignUpAsync(organizationSignup, true);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
ProviderId = providerId,
|
||||
OrganizationId = organization.Id,
|
||||
Key = organizationSignup.OwnerKey,
|
||||
};
|
||||
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
|
||||
|
||||
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
|
||||
new (OrganizationUserInvite, string)[]
|
||||
{
|
||||
(
|
||||
new OrganizationUserInvite
|
||||
{
|
||||
Emails = new[] { clientOwnerEmail },
|
||||
AccessAll = true,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Permissions = null,
|
||||
Collections = Array.Empty<SelectionReadOnly>(),
|
||||
},
|
||||
null
|
||||
)
|
||||
});
|
||||
|
||||
return providerOrganization;
|
||||
}
|
||||
|
||||
public async Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId)
|
||||
{
|
||||
var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(providerOrganizationId);
|
||||
if (providerOrganization == null || providerOrganization.ProviderId != providerId)
|
||||
{
|
||||
throw new BadRequestException("Invalid organization.");
|
||||
}
|
||||
|
||||
if (!await _organizationService.HasConfirmedOwnersExceptAsync(providerOrganization.OrganizationId, new Guid[] { }, includeProvider: false))
|
||||
{
|
||||
throw new BadRequestException("Organization needs to have at least one confirmed owner.");
|
||||
}
|
||||
|
||||
await _providerOrganizationRepository.DeleteAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
|
||||
public async Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
var owner = await _userRepository.GetByIdAsync(ownerId);
|
||||
if (owner == null)
|
||||
{
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
||||
}
|
||||
|
||||
private async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail)
|
||||
{
|
||||
var token = _dataProtector.Protect($"ProviderSetupInvite {provider.Id} {ownerEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
await _mailService.SendProviderSetupInviteEmailAsync(provider, token, ownerEmail);
|
||||
}
|
||||
|
||||
public async Task LogProviderAccessToOrganizationAsync(Guid organizationId)
|
||||
{
|
||||
if (organizationId == default)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var providerOrganization = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
if (providerOrganization != null)
|
||||
{
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_VaultAccessed);
|
||||
}
|
||||
if (organization != null)
|
||||
{
|
||||
await _eventService.LogOrganizationEventAsync(organization, EventType.Organization_VaultAccessed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendInviteAsync(ProviderUser providerUser, Provider provider)
|
||||
{
|
||||
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
|
||||
var token = _dataProtector.Protect(
|
||||
$"ProviderUserInvite {providerUser.Id} {providerUser.Email} {nowMillis}");
|
||||
await _mailService.SendProviderInviteEmailAsync(provider.Name, providerUser, token, providerUser.Email);
|
||||
}
|
||||
|
||||
private async Task<bool> HasConfirmedProviderAdminExceptAsync(Guid providerId, IEnumerable<Guid> providerUserIds)
|
||||
{
|
||||
var providerAdmins = await _providerUserRepository.GetManyByProviderAsync(providerId,
|
||||
ProviderUserType.ProviderAdmin);
|
||||
var confirmedOwners = providerAdmins.Where(o => o.Status == ProviderUserStatusType.Confirmed);
|
||||
var confirmedOwnersIds = confirmedOwners.Select(u => u.Id);
|
||||
return confirmedOwnersIds.Except(providerUserIds).Any();
|
||||
}
|
||||
|
||||
private void ThrowOnInvalidPlanType(PlanType requestedType)
|
||||
{
|
||||
if (ProviderDisllowedOrganizationTypes.Contains(requestedType))
|
||||
{
|
||||
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Commercial.Core.Utilities;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
namespace Bit.Commercial.Core.Utilities
|
||||
{
|
||||
public static void AddCommCoreServices(this IServiceCollection services)
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<IProviderService, ProviderService>();
|
||||
public static void AddCommCoreServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IProviderService, ProviderService>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@ using Bit.Core.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Scim.Context;
|
||||
|
||||
public interface IScimContext
|
||||
namespace Bit.Scim.Context
|
||||
{
|
||||
ScimProviderType RequestScimProvider { get; set; }
|
||||
ScimConfig ScimConfiguration { get; set; }
|
||||
Guid? OrganizationId { get; set; }
|
||||
Organization Organization { get; set; }
|
||||
Task BuildAsync(
|
||||
HttpContext httpContext,
|
||||
GlobalSettings globalSettings,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationConnectionRepository organizationConnectionRepository);
|
||||
public interface IScimContext
|
||||
{
|
||||
ScimProviderType RequestScimProvider { get; set; }
|
||||
ScimConfig ScimConfiguration { get; set; }
|
||||
Guid? OrganizationId { get; set; }
|
||||
Organization Organization { get; set; }
|
||||
Task BuildAsync(
|
||||
HttpContext httpContext,
|
||||
GlobalSettings globalSettings,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationConnectionRepository organizationConnectionRepository);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,60 +4,61 @@ using Bit.Core.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Scim.Context;
|
||||
|
||||
public class ScimContext : IScimContext
|
||||
namespace Bit.Scim.Context
|
||||
{
|
||||
private bool _builtHttpContext;
|
||||
|
||||
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
|
||||
public ScimConfig ScimConfiguration { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public Organization Organization { get; set; }
|
||||
|
||||
public async virtual Task BuildAsync(
|
||||
HttpContext httpContext,
|
||||
GlobalSettings globalSettings,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationConnectionRepository organizationConnectionRepository)
|
||||
public class ScimContext : IScimContext
|
||||
{
|
||||
if (_builtHttpContext)
|
||||
{
|
||||
return;
|
||||
}
|
||||
private bool _builtHttpContext;
|
||||
|
||||
_builtHttpContext = true;
|
||||
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
|
||||
public ScimConfig ScimConfiguration { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public Organization Organization { get; set; }
|
||||
|
||||
string orgIdString = null;
|
||||
if (httpContext.Request.RouteValues.TryGetValue("organizationId", out var orgIdObject))
|
||||
public async virtual Task BuildAsync(
|
||||
HttpContext httpContext,
|
||||
GlobalSettings globalSettings,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationConnectionRepository organizationConnectionRepository)
|
||||
{
|
||||
orgIdString = orgIdObject?.ToString();
|
||||
}
|
||||
|
||||
if (Guid.TryParse(orgIdString, out var orgId))
|
||||
{
|
||||
OrganizationId = orgId;
|
||||
Organization = await organizationRepository.GetByIdAsync(orgId);
|
||||
if (Organization != null)
|
||||
if (_builtHttpContext)
|
||||
{
|
||||
var scimConnections = await organizationConnectionRepository.GetByOrganizationIdTypeAsync(Organization.Id,
|
||||
OrganizationConnectionType.Scim);
|
||||
ScimConfiguration = scimConnections?.FirstOrDefault()?.GetConfig<ScimConfig>();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.TryGetValue("User-Agent", out var userAgent))
|
||||
{
|
||||
if (userAgent.ToString().StartsWith("Okta"))
|
||||
_builtHttpContext = true;
|
||||
|
||||
string orgIdString = null;
|
||||
if (httpContext.Request.RouteValues.TryGetValue("organizationId", out var orgIdObject))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.Okta;
|
||||
orgIdString = orgIdObject?.ToString();
|
||||
}
|
||||
|
||||
if (Guid.TryParse(orgIdString, out var orgId))
|
||||
{
|
||||
OrganizationId = orgId;
|
||||
Organization = await organizationRepository.GetByIdAsync(orgId);
|
||||
if (Organization != null)
|
||||
{
|
||||
var scimConnections = await organizationConnectionRepository.GetByOrganizationIdTypeAsync(Organization.Id,
|
||||
OrganizationConnectionType.Scim);
|
||||
ScimConfiguration = scimConnections?.FirstOrDefault()?.GetConfig<ScimConfig>();
|
||||
}
|
||||
}
|
||||
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.TryGetValue("User-Agent", out var userAgent))
|
||||
{
|
||||
if (userAgent.ToString().StartsWith("Okta"))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.Okta;
|
||||
}
|
||||
}
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.ContainsKey("Adscimversion"))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.AzureAd;
|
||||
}
|
||||
}
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.ContainsKey("Adscimversion"))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.AzureAd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Scim.Controllers;
|
||||
|
||||
[AllowAnonymous]
|
||||
public class InfoController : Controller
|
||||
namespace Bit.Scim.Controllers
|
||||
{
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
[AllowAnonymous]
|
||||
public class InfoController : Controller
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
return Json(CoreHelpers.GetVersion());
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
return Json(CoreHelpers.GetVersion());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,320 +8,321 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Scim.Controllers.v2;
|
||||
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/groups")]
|
||||
public class GroupsController : Controller
|
||||
namespace Bit.Scim.Controllers.v2
|
||||
{
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly ILogger<GroupsController> _logger;
|
||||
|
||||
public GroupsController(
|
||||
IGroupRepository groupRepository,
|
||||
IGroupService groupService,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
IScimContext scimContext,
|
||||
ILogger<GroupsController> logger)
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/groups")]
|
||||
public class GroupsController : Controller
|
||||
{
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_groupRepository = groupRepository;
|
||||
_groupService = groupService;
|
||||
_scimContext = scimContext;
|
||||
_logger = logger;
|
||||
}
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly ILogger<GroupsController> _logger;
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
public GroupsController(
|
||||
IGroupRepository groupRepository,
|
||||
IGroupService groupService,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
IScimContext scimContext,
|
||||
ILogger<GroupsController> logger)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_groupRepository = groupRepository;
|
||||
_groupService = groupService;
|
||||
_scimContext = scimContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
return new ObjectResult(new ScimGroupResponseModel(group));
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get(
|
||||
Guid organizationId,
|
||||
[FromQuery] string filter,
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
string nameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
if (filter.StartsWith("displayName eq "))
|
||||
{
|
||||
nameFilter = filter.Substring(15).Trim('"');
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
var groupList = new List<ScimGroupResponseModel>();
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(nameFilter))
|
||||
{
|
||||
var group = groups.FirstOrDefault(g => g.Name == nameFilter);
|
||||
if (group != null)
|
||||
{
|
||||
groupList.Add(new ScimGroupResponseModel(group));
|
||||
}
|
||||
totalResults = groupList.Count;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
|
||||
{
|
||||
var group = groups.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
|
||||
if (group != null)
|
||||
{
|
||||
groupList.Add(new ScimGroupResponseModel(group));
|
||||
}
|
||||
totalResults = groupList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
{
|
||||
groupList = groups.OrderBy(g => g.Name)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Select(g => new ScimGroupResponseModel(g))
|
||||
.ToList();
|
||||
totalResults = groups.Count;
|
||||
}
|
||||
|
||||
var result = new ScimListResponseModel<ScimGroupResponseModel>
|
||||
{
|
||||
Resources = groupList,
|
||||
ItemsPerPage = count.GetValueOrDefault(groupList.Count),
|
||||
TotalResults = totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
};
|
||||
return new ObjectResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimGroupRequestModel model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.DisplayName))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId) && groups.Any(g => g.ExternalId == model.ExternalId))
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
var group = model.ToGroup(organizationId);
|
||||
await _groupService.SaveAsync(group, null);
|
||||
await UpdateGroupMembersAsync(group, model, true);
|
||||
var response = new ScimGroupResponseModel(group);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { group.OrganizationId, group.Id }), response);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimGroupRequestModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
|
||||
group.Name = model.DisplayName;
|
||||
await _groupService.SaveAsync(group);
|
||||
await UpdateGroupMembersAsync(group, model, false);
|
||||
return new ObjectResult(new ScimGroupResponseModel(group));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Replace a list of members
|
||||
if (operation.Path?.ToLowerInvariant() == "members")
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
var ids = GetOperationValueIds(operation.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids);
|
||||
operationHandled = true;
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
return new ObjectResult(new ScimGroupResponseModel(group));
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get(
|
||||
Guid organizationId,
|
||||
[FromQuery] string filter,
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
string nameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
if (filter.StartsWith("displayName eq "))
|
||||
{
|
||||
nameFilter = filter.Substring(15).Trim('"');
|
||||
}
|
||||
// Replace group name from path
|
||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
group.Name = operation.Value.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
||||
{
|
||||
group.Name = displayNameProperty.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
// Add a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
|
||||
var groupList = new List<ScimGroupResponseModel>();
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(nameFilter))
|
||||
{
|
||||
var addId = GetOperationPathId(operation.Path);
|
||||
if (addId.HasValue)
|
||||
var group = groups.FirstOrDefault(g => g.Name == nameFilter);
|
||||
if (group != null)
|
||||
{
|
||||
groupList.Add(new ScimGroupResponseModel(group));
|
||||
}
|
||||
totalResults = groupList.Count;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
|
||||
{
|
||||
var group = groups.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
|
||||
if (group != null)
|
||||
{
|
||||
groupList.Add(new ScimGroupResponseModel(group));
|
||||
}
|
||||
totalResults = groupList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
{
|
||||
groupList = groups.OrderBy(g => g.Name)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Select(g => new ScimGroupResponseModel(g))
|
||||
.ToList();
|
||||
totalResults = groups.Count;
|
||||
}
|
||||
|
||||
var result = new ScimListResponseModel<ScimGroupResponseModel>
|
||||
{
|
||||
Resources = groupList,
|
||||
ItemsPerPage = count.GetValueOrDefault(groupList.Count),
|
||||
TotalResults = totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
};
|
||||
return new ObjectResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimGroupRequestModel model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.DisplayName))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId) && groups.Any(g => g.ExternalId == model.ExternalId))
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
var group = model.ToGroup(organizationId);
|
||||
await _groupService.SaveAsync(group, null);
|
||||
await UpdateGroupMembersAsync(group, model, true);
|
||||
var response = new ScimGroupResponseModel(group);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { group.OrganizationId, group.Id }), response);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimGroupRequestModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
|
||||
group.Name = model.DisplayName;
|
||||
await _groupService.SaveAsync(group);
|
||||
await UpdateGroupMembersAsync(group, model, false);
|
||||
return new ObjectResult(new ScimGroupResponseModel(group));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Replace a list of members
|
||||
if (operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var ids = GetOperationValueIds(operation.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from path
|
||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
||||
{
|
||||
group.Name = operation.Value.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
||||
{
|
||||
group.Name = displayNameProperty.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Add a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var addId = GetOperationPathId(operation.Path);
|
||||
if (addId.HasValue)
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
orgUserIds.Add(addId.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Add a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
orgUserIds.Add(addId.Value);
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Add(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Remove a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var removeId = GetOperationPathId(operation.Path);
|
||||
if (removeId.HasValue)
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId.Value);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Remove a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Add a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Add(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
_logger.LogWarning("Group patch operation not handled: {0} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
// Remove a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
var removeId = GetOperationPathId(operation.Path);
|
||||
if (removeId.HasValue)
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId.Value);
|
||||
operationHandled = true;
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
await _groupService.DeleteAsync(group);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
private List<Guid> GetOperationValueIds(JsonElement objArray)
|
||||
{
|
||||
var ids = new List<Guid>();
|
||||
foreach (var obj in objArray.EnumerateArray())
|
||||
{
|
||||
if (obj.TryGetProperty("value", out var valueProperty))
|
||||
{
|
||||
if (valueProperty.TryGetGuid(out var guid))
|
||||
{
|
||||
ids.Add(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
return ids;
|
||||
}
|
||||
|
||||
private Guid? GetOperationPathId(string path)
|
||||
{
|
||||
// Parse Guid from string like: members[value eq "{GUID}"}]
|
||||
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model, bool skipIfEmpty)
|
||||
{
|
||||
_logger.LogWarning("Group patch operation not handled: {0} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta)
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
await _groupService.DeleteAsync(group);
|
||||
return new NoContentResult();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private List<Guid> GetOperationValueIds(JsonElement objArray)
|
||||
{
|
||||
var ids = new List<Guid>();
|
||||
foreach (var obj in objArray.EnumerateArray())
|
||||
{
|
||||
if (obj.TryGetProperty("value", out var valueProperty))
|
||||
if (model.Members == null)
|
||||
{
|
||||
if (valueProperty.TryGetGuid(out var guid))
|
||||
return;
|
||||
}
|
||||
|
||||
var memberIds = new List<Guid>();
|
||||
foreach (var id in model.Members.Select(i => i.Value))
|
||||
{
|
||||
if (Guid.TryParse(id, out var guidId))
|
||||
{
|
||||
ids.Add(guid);
|
||||
memberIds.Add(guidId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private Guid? GetOperationPathId(string path)
|
||||
{
|
||||
// Parse Guid from string like: members[value eq "{GUID}"}]
|
||||
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model, bool skipIfEmpty)
|
||||
{
|
||||
if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.Members == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var memberIds = new List<Guid>();
|
||||
foreach (var id in model.Members.Select(i => i.Value))
|
||||
{
|
||||
if (Guid.TryParse(id, out var guidId))
|
||||
if (!memberIds.Any() && skipIfEmpty)
|
||||
{
|
||||
memberIds.Add(guidId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!memberIds.Any() && skipIfEmpty)
|
||||
{
|
||||
return;
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, memberIds);
|
||||
}
|
||||
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, memberIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,286 +9,287 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Scim.Controllers.v2;
|
||||
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/users")]
|
||||
public class UsersController : Controller
|
||||
namespace Bit.Scim.Controllers.v2
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
|
||||
public UsersController(
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IScimContext scimContext,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
ILogger<UsersController> logger)
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/users")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_scimContext = scimContext;
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
public UsersController(
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IScimContext scimContext,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
ILogger<UsersController> logger)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_scimContext = scimContext;
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
return new ObjectResult(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get(
|
||||
Guid organizationId,
|
||||
[FromQuery] string filter,
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
string emailFilter = null;
|
||||
string usernameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
if (filter.StartsWith("userName eq "))
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
||||
if (usernameFilter.Contains("@"))
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
return new ObjectResult(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get(
|
||||
Guid organizationId,
|
||||
[FromQuery] string filter,
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
string emailFilter = null;
|
||||
string usernameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
var userList = new List<ScimUserResponseModel> { };
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(emailFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.Email.ToLowerInvariant() == emailFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
{
|
||||
userList = orgUsers.OrderBy(ou => ou.Email)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Select(ou => new ScimUserResponseModel(ou))
|
||||
.ToList();
|
||||
totalResults = orgUsers.Count;
|
||||
}
|
||||
|
||||
var result = new ScimListResponseModel<ScimUserResponseModel>
|
||||
{
|
||||
Resources = userList,
|
||||
ItemsPerPage = count.GetValueOrDefault(userList.Count),
|
||||
TotalResults = totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
};
|
||||
return new ObjectResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var email = model.PrimaryEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
switch (_scimContext.RequestScimProvider)
|
||||
{
|
||||
case ScimProviderType.AzureAd:
|
||||
email = model.UserName?.ToLowerInvariant();
|
||||
break;
|
||||
default:
|
||||
email = model.WorkEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !model.Active)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||
if (orgUserByEmail != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
string externalId = null;
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId))
|
||||
{
|
||||
externalId = model.ExternalId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.UserName))
|
||||
{
|
||||
externalId = model.UserName;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalId = CoreHelpers.RandomString(15);
|
||||
}
|
||||
|
||||
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
|
||||
if (orgUserByExternalId != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, null, email,
|
||||
OrganizationUserType.User, false, externalId, new List<SelectionReadOnly>());
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
var response = new ScimUserResponseModel(orgUser);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { orgUser.OrganizationId, orgUser.Id }), response);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, null);
|
||||
}
|
||||
|
||||
// Have to get full details object for response model
|
||||
var orgUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
return new ObjectResult(new ScimUserResponseModel(orgUserDetails));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Active from path
|
||||
if (operation.Path?.ToLowerInvariant() == "active")
|
||||
if (filter.StartsWith("userName eq "))
|
||||
{
|
||||
var active = operation.Value.ToString()?.ToLowerInvariant();
|
||||
var handled = await HandleActiveOperationAsync(orgUser, active == "true");
|
||||
if (!operationHandled)
|
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
||||
if (usernameFilter.Contains("@"))
|
||||
{
|
||||
operationHandled = handled;
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
}
|
||||
// Active from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("active", out var activeProperty))
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
var handled = await HandleActiveOperationAsync(orgUser, activeProperty.GetBoolean());
|
||||
if (!operationHandled)
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
var userList = new List<ScimUserResponseModel> { };
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(emailFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.Email.ToLowerInvariant() == emailFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
{
|
||||
userList = orgUsers.OrderBy(ou => ou.Email)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Select(ou => new ScimUserResponseModel(ou))
|
||||
.ToList();
|
||||
totalResults = orgUsers.Count;
|
||||
}
|
||||
|
||||
var result = new ScimListResponseModel<ScimUserResponseModel>
|
||||
{
|
||||
Resources = userList,
|
||||
ItemsPerPage = count.GetValueOrDefault(userList.Count),
|
||||
TotalResults = totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
};
|
||||
return new ObjectResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var email = model.PrimaryEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
switch (_scimContext.RequestScimProvider)
|
||||
{
|
||||
case ScimProviderType.AzureAd:
|
||||
email = model.UserName?.ToLowerInvariant();
|
||||
break;
|
||||
default:
|
||||
email = model.WorkEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !model.Active)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||
if (orgUserByEmail != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
string externalId = null;
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId))
|
||||
{
|
||||
externalId = model.ExternalId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.UserName))
|
||||
{
|
||||
externalId = model.UserName;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalId = CoreHelpers.RandomString(15);
|
||||
}
|
||||
|
||||
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
|
||||
if (orgUserByExternalId != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, null, email,
|
||||
OrganizationUserType.User, false, externalId, new List<SelectionReadOnly>());
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
var response = new ScimUserResponseModel(orgUser);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { orgUser.OrganizationId, orgUser.Id }), response);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, null);
|
||||
}
|
||||
|
||||
// Have to get full details object for response model
|
||||
var orgUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
return new ObjectResult(new ScimUserResponseModel(orgUserDetails));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Active from path
|
||||
if (operation.Path?.ToLowerInvariant() == "active")
|
||||
{
|
||||
operationHandled = handled;
|
||||
var active = operation.Value.ToString()?.ToLowerInvariant();
|
||||
var handled = await HandleActiveOperationAsync(orgUser, active == "true");
|
||||
if (!operationHandled)
|
||||
{
|
||||
operationHandled = handled;
|
||||
}
|
||||
}
|
||||
// Active from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("active", out var activeProperty))
|
||||
{
|
||||
var handled = await HandleActiveOperationAsync(orgUser, activeProperty.GetBoolean());
|
||||
if (!operationHandled)
|
||||
{
|
||||
operationHandled = handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
_logger.LogWarning("User patch operation not handled: {operation} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
if (!operationHandled)
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
await _organizationService.DeleteUserAsync(organizationId, id, null);
|
||||
return new NoContentResult();
|
||||
}
|
||||
_logger.LogWarning("User patch operation not handled: {operation} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
|
||||
private async Task<bool> HandleActiveOperationAsync(Core.Entities.OrganizationUser orgUser, bool active)
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
return true;
|
||||
return new NoContentResult();
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, null);
|
||||
return true;
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
await _organizationService.DeleteUserAsync(organizationId, id, null);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
private async Task<bool> HandleActiveOperationAsync(Core.Entities.OrganizationUser orgUser, bool active)
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
return true;
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public abstract class BaseScimGroupModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public BaseScimGroupModel(bool initSchema = false)
|
||||
public abstract class BaseScimGroupModel : BaseScimModel
|
||||
{
|
||||
if (initSchema)
|
||||
public BaseScimGroupModel(bool initSchema = false)
|
||||
{
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup };
|
||||
if (initSchema)
|
||||
{
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string DisplayName { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public abstract class BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public BaseScimModel()
|
||||
{ }
|
||||
|
||||
public BaseScimModel(string schema)
|
||||
public abstract class BaseScimModel
|
||||
{
|
||||
Schemas = new List<string> { schema };
|
||||
}
|
||||
public BaseScimModel()
|
||||
{ }
|
||||
|
||||
public List<string> Schemas { get; set; }
|
||||
public BaseScimModel(string schema)
|
||||
{
|
||||
Schemas = new List<string> { schema };
|
||||
}
|
||||
|
||||
public List<string> Schemas { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public abstract class BaseScimUserModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public BaseScimUserModel(bool initSchema = false)
|
||||
public abstract class BaseScimUserModel : BaseScimModel
|
||||
{
|
||||
if (initSchema)
|
||||
public BaseScimUserModel(bool initSchema = false)
|
||||
{
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser };
|
||||
}
|
||||
}
|
||||
|
||||
public string UserName { get; set; }
|
||||
public NameModel Name { get; set; }
|
||||
public List<EmailModel> Emails { get; set; }
|
||||
public string PrimaryEmail => Emails?.FirstOrDefault(e => e.Primary)?.Value;
|
||||
public string WorkEmail => Emails?.FirstOrDefault(e => e.Type == "work")?.Value;
|
||||
public string DisplayName { get; set; }
|
||||
public bool Active { get; set; }
|
||||
public List<string> Groups { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
|
||||
public class NameModel
|
||||
{
|
||||
public NameModel() { }
|
||||
|
||||
public NameModel(string name)
|
||||
{
|
||||
Formatted = name;
|
||||
if (initSchema)
|
||||
{
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser };
|
||||
}
|
||||
}
|
||||
|
||||
public string Formatted { get; set; }
|
||||
public string GivenName { get; set; }
|
||||
public string MiddleName { get; set; }
|
||||
public string FamilyName { get; set; }
|
||||
}
|
||||
public string UserName { get; set; }
|
||||
public NameModel Name { get; set; }
|
||||
public List<EmailModel> Emails { get; set; }
|
||||
public string PrimaryEmail => Emails?.FirstOrDefault(e => e.Primary)?.Value;
|
||||
public string WorkEmail => Emails?.FirstOrDefault(e => e.Type == "work")?.Value;
|
||||
public string DisplayName { get; set; }
|
||||
public bool Active { get; set; }
|
||||
public List<string> Groups { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
|
||||
public class EmailModel
|
||||
{
|
||||
public EmailModel() { }
|
||||
|
||||
public EmailModel(string email)
|
||||
public class NameModel
|
||||
{
|
||||
Primary = true;
|
||||
Value = email;
|
||||
Type = "work";
|
||||
public NameModel() { }
|
||||
|
||||
public NameModel(string name)
|
||||
{
|
||||
Formatted = name;
|
||||
}
|
||||
|
||||
public string Formatted { get; set; }
|
||||
public string GivenName { get; set; }
|
||||
public string MiddleName { get; set; }
|
||||
public string FamilyName { get; set; }
|
||||
}
|
||||
|
||||
public bool Primary { get; set; }
|
||||
public string Value { get; set; }
|
||||
public string Type { get; set; }
|
||||
public class EmailModel
|
||||
{
|
||||
public EmailModel() { }
|
||||
|
||||
public EmailModel(string email)
|
||||
{
|
||||
Primary = true;
|
||||
Value = email;
|
||||
Type = "work";
|
||||
}
|
||||
|
||||
public bool Primary { get; set; }
|
||||
public string Value { get; set; }
|
||||
public string Type { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimErrorResponseModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimErrorResponseModel()
|
||||
: base(ScimConstants.Scim2SchemaError)
|
||||
{ }
|
||||
public class ScimErrorResponseModel : BaseScimModel
|
||||
{
|
||||
public ScimErrorResponseModel()
|
||||
: base(ScimConstants.Scim2SchemaError)
|
||||
{ }
|
||||
|
||||
public string Detail { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string Detail { get; set; }
|
||||
public int Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimGroupRequestModel : BaseScimGroupModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimGroupRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
|
||||
public Group ToGroup(Guid organizationId)
|
||||
public class ScimGroupRequestModel : BaseScimGroupModel
|
||||
{
|
||||
var externalId = string.IsNullOrWhiteSpace(ExternalId) ? CoreHelpers.RandomString(15) : ExternalId;
|
||||
return new Group
|
||||
public ScimGroupRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
|
||||
public Group ToGroup(Guid organizationId)
|
||||
{
|
||||
Name = DisplayName,
|
||||
ExternalId = externalId,
|
||||
OrganizationId = organizationId
|
||||
};
|
||||
}
|
||||
var externalId = string.IsNullOrWhiteSpace(ExternalId) ? CoreHelpers.RandomString(15) : ExternalId;
|
||||
return new Group
|
||||
{
|
||||
Name = DisplayName,
|
||||
ExternalId = externalId,
|
||||
OrganizationId = organizationId
|
||||
};
|
||||
}
|
||||
|
||||
public List<GroupMembersModel> Members { get; set; }
|
||||
public List<GroupMembersModel> Members { get; set; }
|
||||
|
||||
public class GroupMembersModel
|
||||
{
|
||||
public string Value { get; set; }
|
||||
public string Display { get; set; }
|
||||
public class GroupMembersModel
|
||||
{
|
||||
public string Value { get; set; }
|
||||
public string Display { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimGroupResponseModel : BaseScimGroupModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimGroupResponseModel()
|
||||
: base(true)
|
||||
public class ScimGroupResponseModel : BaseScimGroupModel
|
||||
{
|
||||
Meta = new ScimMetaModel("Group");
|
||||
}
|
||||
public ScimGroupResponseModel()
|
||||
: base(true)
|
||||
{
|
||||
Meta = new ScimMetaModel("Group");
|
||||
}
|
||||
|
||||
public ScimGroupResponseModel(Group group)
|
||||
: this()
|
||||
{
|
||||
Id = group.Id.ToString();
|
||||
DisplayName = group.Name;
|
||||
ExternalId = group.ExternalId;
|
||||
Meta.Created = group.CreationDate;
|
||||
Meta.LastModified = group.RevisionDate;
|
||||
}
|
||||
public ScimGroupResponseModel(Group group)
|
||||
: this()
|
||||
{
|
||||
Id = group.Id.ToString();
|
||||
DisplayName = group.Name;
|
||||
ExternalId = group.ExternalId;
|
||||
Meta.Created = group.CreationDate;
|
||||
Meta.LastModified = group.RevisionDate;
|
||||
}
|
||||
|
||||
public string Id { get; set; }
|
||||
public ScimMetaModel Meta { get; private set; }
|
||||
public string Id { get; set; }
|
||||
public ScimMetaModel Meta { get; private set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimListResponseModel<T> : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimListResponseModel()
|
||||
: base(ScimConstants.Scim2SchemaListResponse)
|
||||
{ }
|
||||
public class ScimListResponseModel<T> : BaseScimModel
|
||||
{
|
||||
public ScimListResponseModel()
|
||||
: base(ScimConstants.Scim2SchemaListResponse)
|
||||
{ }
|
||||
|
||||
public int TotalResults { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
public int ItemsPerPage { get; set; }
|
||||
public List<T> Resources { get; set; }
|
||||
public int TotalResults { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
public int ItemsPerPage { get; set; }
|
||||
public List<T> Resources { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimMetaModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimMetaModel(string resourceType)
|
||||
public class ScimMetaModel
|
||||
{
|
||||
ResourceType = resourceType;
|
||||
}
|
||||
public ScimMetaModel(string resourceType)
|
||||
{
|
||||
ResourceType = resourceType;
|
||||
}
|
||||
|
||||
public string ResourceType { get; set; }
|
||||
public DateTime? Created { get; set; }
|
||||
public DateTime? LastModified { get; set; }
|
||||
public string ResourceType { get; set; }
|
||||
public DateTime? Created { get; set; }
|
||||
public DateTime? LastModified { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimPatchModel : BaseScimModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimPatchModel()
|
||||
: base() { }
|
||||
|
||||
public List<OperationModel> Operations { get; set; }
|
||||
|
||||
public class OperationModel
|
||||
public class ScimPatchModel : BaseScimModel
|
||||
{
|
||||
public string Op { get; set; }
|
||||
public string Path { get; set; }
|
||||
public JsonElement Value { get; set; }
|
||||
public ScimPatchModel()
|
||||
: base() { }
|
||||
|
||||
public List<OperationModel> Operations { get; set; }
|
||||
|
||||
public class OperationModel
|
||||
{
|
||||
public string Op { get; set; }
|
||||
public string Path { get; set; }
|
||||
public JsonElement Value { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimUserRequestModel : BaseScimUserModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
public class ScimUserRequestModel : BaseScimUserModel
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimUserResponseModel : BaseScimUserModel
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public ScimUserResponseModel()
|
||||
: base(true)
|
||||
public class ScimUserResponseModel : BaseScimUserModel
|
||||
{
|
||||
Meta = new ScimMetaModel("User");
|
||||
Groups = new List<string>();
|
||||
}
|
||||
public ScimUserResponseModel()
|
||||
: base(true)
|
||||
{
|
||||
Meta = new ScimMetaModel("User");
|
||||
Groups = new List<string>();
|
||||
}
|
||||
|
||||
public ScimUserResponseModel(OrganizationUserUserDetails orgUser)
|
||||
: this()
|
||||
{
|
||||
Id = orgUser.Id.ToString();
|
||||
ExternalId = orgUser.ExternalId;
|
||||
UserName = orgUser.Email;
|
||||
DisplayName = orgUser.Name;
|
||||
Emails = new List<EmailModel> { new EmailModel(orgUser.Email) };
|
||||
Name = new NameModel(orgUser.Name);
|
||||
Active = orgUser.Status != Core.Enums.OrganizationUserStatusType.Revoked;
|
||||
}
|
||||
public ScimUserResponseModel(OrganizationUserUserDetails orgUser)
|
||||
: this()
|
||||
{
|
||||
Id = orgUser.Id.ToString();
|
||||
ExternalId = orgUser.ExternalId;
|
||||
UserName = orgUser.Email;
|
||||
DisplayName = orgUser.Name;
|
||||
Emails = new List<EmailModel> { new EmailModel(orgUser.Email) };
|
||||
Name = new NameModel(orgUser.Name);
|
||||
Active = orgUser.Status != Core.Enums.OrganizationUserStatusType.Revoked;
|
||||
}
|
||||
|
||||
public string Id { get; set; }
|
||||
public ScimMetaModel Meta { get; private set; }
|
||||
public string Id { get; set; }
|
||||
public ScimMetaModel Meta { get; private set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Bit.Scim;
|
||||
|
||||
public class Program
|
||||
namespace Bit.Scim
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
||||
logging.AddSerilog(hostingContext, e =>
|
||||
{
|
||||
var context = e.Properties["SourceContext"].ToString();
|
||||
|
||||
if (e.Properties.ContainsKey("RequestPath") &&
|
||||
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
|
||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
||||
logging.AddSerilog(hostingContext, e =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var context = e.Properties["SourceContext"].ToString();
|
||||
|
||||
return e.Level >= LogEventLevel.Warning;
|
||||
}));
|
||||
})
|
||||
.Build()
|
||||
.Run();
|
||||
if (e.Properties.ContainsKey("RequestPath") &&
|
||||
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
|
||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return e.Level >= LogEventLevel.Warning;
|
||||
}));
|
||||
})
|
||||
.Build()
|
||||
.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace Bit.Scim;
|
||||
|
||||
public class ScimSettings
|
||||
namespace Bit.Scim
|
||||
{
|
||||
public class ScimSettings
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,107 +9,108 @@ using IdentityModel;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Scim;
|
||||
|
||||
public class Startup
|
||||
namespace Bit.Scim
|
||||
{
|
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration)
|
||||
public class Startup
|
||||
{
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
|
||||
Configuration = configuration;
|
||||
Environment = env;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
public IWebHostEnvironment Environment { get; set; }
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Options
|
||||
services.AddOptions();
|
||||
|
||||
// Settings
|
||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
||||
services.Configure<ScimSettings>(Configuration.GetSection("ScimSettings"));
|
||||
|
||||
// Data Protection
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
// Stripe Billing
|
||||
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
|
||||
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries;
|
||||
|
||||
// Repositories
|
||||
services.AddSqlServerRepositories(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
services.AddScoped<IScimContext, ScimContext>();
|
||||
|
||||
// Authentication
|
||||
services.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
|
||||
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationOptions.DefaultScheme, null);
|
||||
|
||||
services.AddAuthorization(config =>
|
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration)
|
||||
{
|
||||
config.AddPolicy("Scim", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireClaim(JwtClaimTypes.Scope, "api.scim");
|
||||
});
|
||||
});
|
||||
|
||||
// Identity
|
||||
services.AddCustomIdentityServices(globalSettings);
|
||||
|
||||
// Services
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
|
||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
|
||||
// Mvc
|
||||
services.AddMvc(config =>
|
||||
{
|
||||
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
|
||||
});
|
||||
services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
app.UseSerilog(env, appLifetime, globalSettings);
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
|
||||
Configuration = configuration;
|
||||
Environment = env;
|
||||
}
|
||||
|
||||
// Default Middleware
|
||||
app.UseDefaultMiddleware(env, globalSettings);
|
||||
public IConfiguration Configuration { get; }
|
||||
public IWebHostEnvironment Environment { get; set; }
|
||||
|
||||
// Add routing
|
||||
app.UseRouting();
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Options
|
||||
services.AddOptions();
|
||||
|
||||
// Add Scim context
|
||||
app.UseMiddleware<ScimContextMiddleware>();
|
||||
// Settings
|
||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
||||
services.Configure<ScimSettings>(Configuration.GetSection("ScimSettings"));
|
||||
|
||||
// Add authentication and authorization to the request pipeline.
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
// Data Protection
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
// Add current context
|
||||
app.UseMiddleware<CurrentContextMiddleware>();
|
||||
// Stripe Billing
|
||||
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
|
||||
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries;
|
||||
|
||||
// Add MVC to the request pipeline.
|
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
|
||||
// Repositories
|
||||
services.AddSqlServerRepositories(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
services.AddScoped<IScimContext, ScimContext>();
|
||||
|
||||
// Authentication
|
||||
services.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
|
||||
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationOptions.DefaultScheme, null);
|
||||
|
||||
services.AddAuthorization(config =>
|
||||
{
|
||||
config.AddPolicy("Scim", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireClaim(JwtClaimTypes.Scope, "api.scim");
|
||||
});
|
||||
});
|
||||
|
||||
// Identity
|
||||
services.AddCustomIdentityServices(globalSettings);
|
||||
|
||||
// Services
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
|
||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
|
||||
// Mvc
|
||||
services.AddMvc(config =>
|
||||
{
|
||||
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
|
||||
});
|
||||
services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
app.UseSerilog(env, appLifetime, globalSettings);
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
|
||||
// Default Middleware
|
||||
app.UseDefaultMiddleware(env, globalSettings);
|
||||
|
||||
// Add routing
|
||||
app.UseRouting();
|
||||
|
||||
// Add Scim context
|
||||
app.UseMiddleware<ScimContextMiddleware>();
|
||||
|
||||
// Add authentication and authorization to the request pipeline.
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Add current context
|
||||
app.UseMiddleware<CurrentContextMiddleware>();
|
||||
|
||||
// Add MVC to the request pipeline.
|
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,82 +8,83 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
|
||||
private readonly IScimContext _scimContext;
|
||||
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
IScimContext scimContext) :
|
||||
base(options, logger, encoder, clock)
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationApiKeyRepository = organizationApiKeyRepository;
|
||||
_scimContext = scimContext;
|
||||
}
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
|
||||
private readonly IScimContext _scimContext;
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var endpoint = Context.GetEndpoint();
|
||||
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
IScimContext scimContext) :
|
||||
base(options, logger, encoder, clock)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationApiKeyRepository = organizationApiKeyRepository;
|
||||
_scimContext = scimContext;
|
||||
}
|
||||
|
||||
if (!_scimContext.OrganizationId.HasValue || _scimContext.Organization == null)
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
Logger.LogWarning("No organization.");
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
var endpoint = Context.GetEndpoint();
|
||||
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
if (!_scimContext.OrganizationId.HasValue || _scimContext.Organization == null)
|
||||
{
|
||||
Logger.LogWarning("No organization.");
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
}
|
||||
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader) || authHeader.Count != 1)
|
||||
{
|
||||
Logger.LogWarning("An API request was received without the Authorization header");
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
}
|
||||
var apiKey = authHeader.ToString();
|
||||
if (apiKey.StartsWith("Bearer "))
|
||||
{
|
||||
apiKey = apiKey.Substring(7);
|
||||
}
|
||||
|
||||
if (!_scimContext.Organization.Enabled || !_scimContext.Organization.UseScim ||
|
||||
_scimContext.ScimConfiguration == null || !_scimContext.ScimConfiguration.Enabled)
|
||||
{
|
||||
Logger.LogInformation("Org {organizationId} not able to use Scim.", _scimContext.OrganizationId);
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
}
|
||||
|
||||
var orgApiKey = (await _organizationApiKeyRepository
|
||||
.GetManyByOrganizationIdTypeAsync(_scimContext.Organization.Id, OrganizationApiKeyType.Scim))
|
||||
.FirstOrDefault();
|
||||
if (orgApiKey?.ApiKey != apiKey)
|
||||
{
|
||||
Logger.LogWarning("An API request was received with an invalid API key: {apiKey}", apiKey);
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
}
|
||||
|
||||
Logger.LogInformation("Org {organizationId} authenticated", _scimContext.OrganizationId);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtClaimTypes.ClientId, $"organization.{_scimContext.OrganizationId.Value}"),
|
||||
new Claim("client_sub", _scimContext.OrganizationId.Value.ToString()),
|
||||
new Claim(JwtClaimTypes.Scope, "api.scim"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, nameof(ApiKeyAuthenticationHandler));
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity),
|
||||
ApiKeyAuthenticationOptions.DefaultScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader) || authHeader.Count != 1)
|
||||
{
|
||||
Logger.LogWarning("An API request was received without the Authorization header");
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
}
|
||||
var apiKey = authHeader.ToString();
|
||||
if (apiKey.StartsWith("Bearer "))
|
||||
{
|
||||
apiKey = apiKey.Substring(7);
|
||||
}
|
||||
|
||||
if (!_scimContext.Organization.Enabled || !_scimContext.Organization.UseScim ||
|
||||
_scimContext.ScimConfiguration == null || !_scimContext.ScimConfiguration.Enabled)
|
||||
{
|
||||
Logger.LogInformation("Org {organizationId} not able to use Scim.", _scimContext.OrganizationId);
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
}
|
||||
|
||||
var orgApiKey = (await _organizationApiKeyRepository
|
||||
.GetManyByOrganizationIdTypeAsync(_scimContext.Organization.Id, OrganizationApiKeyType.Scim))
|
||||
.FirstOrDefault();
|
||||
if (orgApiKey?.ApiKey != apiKey)
|
||||
{
|
||||
Logger.LogWarning("An API request was received with an invalid API key: {apiKey}", apiKey);
|
||||
return AuthenticateResult.Fail("Invalid parameters");
|
||||
}
|
||||
|
||||
Logger.LogInformation("Org {organizationId} authenticated", _scimContext.OrganizationId);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtClaimTypes.ClientId, $"organization.{_scimContext.OrganizationId.Value}"),
|
||||
new Claim("client_sub", _scimContext.OrganizationId.Value.ToString()),
|
||||
new Claim(JwtClaimTypes.Scope, "api.scim"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, nameof(ApiKeyAuthenticationHandler));
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity),
|
||||
ApiKeyAuthenticationOptions.DefaultScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
public const string DefaultScheme = "ScimApiKey";
|
||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
public const string DefaultScheme = "ScimApiKey";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public static class ScimConstants
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
public const string Scim2SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
||||
public const string Scim2SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error";
|
||||
public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User";
|
||||
public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group";
|
||||
public static class ScimConstants
|
||||
{
|
||||
public const string Scim2SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
||||
public const string Scim2SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error";
|
||||
public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User";
|
||||
public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Scim.Context;
|
||||
|
||||
namespace Bit.Scim.Utilities;
|
||||
|
||||
public class ScimContextMiddleware
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public ScimContextMiddleware(RequestDelegate next)
|
||||
public class ScimContextMiddleware
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public async Task Invoke(HttpContext httpContext, IScimContext scimContext, GlobalSettings globalSettings,
|
||||
IOrganizationRepository organizationRepository, IOrganizationConnectionRepository organizationConnectionRepository)
|
||||
{
|
||||
await scimContext.BuildAsync(httpContext, globalSettings, organizationRepository, organizationConnectionRepository);
|
||||
await _next.Invoke(httpContext);
|
||||
public ScimContextMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task Invoke(HttpContext httpContext, IScimContext scimContext, GlobalSettings globalSettings,
|
||||
IOrganizationRepository organizationRepository, IOrganizationConnectionRepository organizationConnectionRepository)
|
||||
{
|
||||
await scimContext.BuildAsync(httpContext, globalSettings, organizationRepository, organizationConnectionRepository);
|
||||
await _next.Invoke(httpContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,50 +5,51 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Sso.Controllers;
|
||||
|
||||
public class HomeController : Controller
|
||||
namespace Bit.Sso.Controllers
|
||||
{
|
||||
private readonly IIdentityServerInteractionService _interaction;
|
||||
|
||||
public HomeController(IIdentityServerInteractionService interaction)
|
||||
public class HomeController : Controller
|
||||
{
|
||||
_interaction = interaction;
|
||||
}
|
||||
private readonly IIdentityServerInteractionService _interaction;
|
||||
|
||||
[Route("~/Error")]
|
||||
[Route("~/Home/Error")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Error(string errorId)
|
||||
{
|
||||
var vm = new ErrorViewModel();
|
||||
|
||||
// retrieve error details from identityserver
|
||||
var message = string.IsNullOrWhiteSpace(errorId) ? null :
|
||||
await _interaction.GetErrorContextAsync(errorId);
|
||||
if (message != null)
|
||||
public HomeController(IIdentityServerInteractionService interaction)
|
||||
{
|
||||
vm.Error = message;
|
||||
_interaction = interaction;
|
||||
}
|
||||
else
|
||||
|
||||
[Route("~/Error")]
|
||||
[Route("~/Home/Error")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Error(string errorId)
|
||||
{
|
||||
vm.RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
var exceptionHandlerPathFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
|
||||
var exception = exceptionHandlerPathFeature?.Error;
|
||||
if (exception is InvalidOperationException opEx && opEx.Message.Contains("schemes are: "))
|
||||
var vm = new ErrorViewModel();
|
||||
|
||||
// retrieve error details from identityserver
|
||||
var message = string.IsNullOrWhiteSpace(errorId) ? null :
|
||||
await _interaction.GetErrorContextAsync(errorId);
|
||||
if (message != null)
|
||||
{
|
||||
// Messages coming from aspnetcore with a message
|
||||
// similar to "The registered sign-in schemes are: {schemes}."
|
||||
// will expose other Org IDs and sign-in schemes enabled on
|
||||
// the server. These errors should be truncated to just the
|
||||
// scheme impacted (always the first sentence)
|
||||
var cleanupPoint = opEx.Message.IndexOf(". ") + 1;
|
||||
var exMessage = opEx.Message.Substring(0, cleanupPoint);
|
||||
exception = new InvalidOperationException(exMessage, opEx);
|
||||
vm.Error = message;
|
||||
}
|
||||
else
|
||||
{
|
||||
vm.RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
var exceptionHandlerPathFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
|
||||
var exception = exceptionHandlerPathFeature?.Error;
|
||||
if (exception is InvalidOperationException opEx && opEx.Message.Contains("schemes are: "))
|
||||
{
|
||||
// Messages coming from aspnetcore with a message
|
||||
// similar to "The registered sign-in schemes are: {schemes}."
|
||||
// will expose other Org IDs and sign-in schemes enabled on
|
||||
// the server. These errors should be truncated to just the
|
||||
// scheme impacted (always the first sentence)
|
||||
var cleanupPoint = opEx.Message.IndexOf(". ") + 1;
|
||||
var exMessage = opEx.Message.Substring(0, cleanupPoint);
|
||||
exception = new InvalidOperationException(exMessage, opEx);
|
||||
}
|
||||
vm.Exception = exception;
|
||||
}
|
||||
vm.Exception = exception;
|
||||
}
|
||||
|
||||
return View("Error", vm);
|
||||
return View("Error", vm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Sso.Controllers;
|
||||
|
||||
public class InfoController : Controller
|
||||
namespace Bit.Sso.Controllers
|
||||
{
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
public class InfoController : Controller
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
return Json(CoreHelpers.GetVersion());
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
return Json(CoreHelpers.GetVersion());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,65 +5,66 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
using Sustainsys.Saml2.WebSso;
|
||||
|
||||
namespace Bit.Sso.Controllers;
|
||||
|
||||
public class MetadataController : Controller
|
||||
namespace Bit.Sso.Controllers
|
||||
{
|
||||
private readonly IAuthenticationSchemeProvider _schemeProvider;
|
||||
|
||||
public MetadataController(
|
||||
IAuthenticationSchemeProvider schemeProvider)
|
||||
public class MetadataController : Controller
|
||||
{
|
||||
_schemeProvider = schemeProvider;
|
||||
}
|
||||
private readonly IAuthenticationSchemeProvider _schemeProvider;
|
||||
|
||||
[HttpGet("saml2/{scheme}")]
|
||||
public async Task<IActionResult> ViewAsync(string scheme)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scheme))
|
||||
public MetadataController(
|
||||
IAuthenticationSchemeProvider schemeProvider)
|
||||
{
|
||||
return NotFound();
|
||||
_schemeProvider = schemeProvider;
|
||||
}
|
||||
|
||||
var authScheme = await _schemeProvider.GetSchemeAsync(scheme);
|
||||
if (authScheme == null ||
|
||||
!(authScheme is DynamicAuthenticationScheme dynamicAuthScheme) ||
|
||||
dynamicAuthScheme?.SsoType != SsoType.Saml2)
|
||||
[HttpGet("saml2/{scheme}")]
|
||||
public async Task<IActionResult> ViewAsync(string scheme)
|
||||
{
|
||||
return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(scheme))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var authScheme = await _schemeProvider.GetSchemeAsync(scheme);
|
||||
if (authScheme == null ||
|
||||
!(authScheme is DynamicAuthenticationScheme dynamicAuthScheme) ||
|
||||
dynamicAuthScheme?.SsoType != SsoType.Saml2)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!(dynamicAuthScheme.Options is Saml2Options options))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var uri = new Uri(
|
||||
Request.Scheme
|
||||
+ "://"
|
||||
+ Request.Host
|
||||
+ Request.Path
|
||||
+ Request.QueryString);
|
||||
|
||||
var pathBase = Request.PathBase.Value;
|
||||
pathBase = string.IsNullOrEmpty(pathBase) ? "/" : pathBase;
|
||||
|
||||
var requestdata = new HttpRequestData(
|
||||
Request.Method,
|
||||
uri,
|
||||
pathBase,
|
||||
null,
|
||||
Request.Cookies,
|
||||
(data) => data);
|
||||
|
||||
var metadataResult = CommandFactory
|
||||
.GetCommand(CommandFactory.MetadataCommand)
|
||||
.Run(requestdata, options);
|
||||
//Response.Headers.Add("Content-Disposition", $"filename= bitwarden-saml2-meta-{scheme}.xml");
|
||||
return new ContentResult
|
||||
{
|
||||
Content = metadataResult.Content,
|
||||
ContentType = "text/xml",
|
||||
};
|
||||
}
|
||||
|
||||
if (!(dynamicAuthScheme.Options is Saml2Options options))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var uri = new Uri(
|
||||
Request.Scheme
|
||||
+ "://"
|
||||
+ Request.Host
|
||||
+ Request.Path
|
||||
+ Request.QueryString);
|
||||
|
||||
var pathBase = Request.PathBase.Value;
|
||||
pathBase = string.IsNullOrEmpty(pathBase) ? "/" : pathBase;
|
||||
|
||||
var requestdata = new HttpRequestData(
|
||||
Request.Method,
|
||||
uri,
|
||||
pathBase,
|
||||
null,
|
||||
Request.Cookies,
|
||||
(data) => data);
|
||||
|
||||
var metadataResult = CommandFactory
|
||||
.GetCommand(CommandFactory.MetadataCommand)
|
||||
.Run(requestdata, options);
|
||||
//Response.Headers.Add("Content-Disposition", $"filename= bitwarden-saml2-meta-{scheme}.xml");
|
||||
return new ContentResult
|
||||
{
|
||||
Content = metadataResult.Content,
|
||||
ContentType = "text/xml",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
using IdentityServer4.Models;
|
||||
|
||||
namespace Bit.Sso.Models;
|
||||
|
||||
public class ErrorViewModel
|
||||
namespace Bit.Sso.Models
|
||||
{
|
||||
private string _requestId;
|
||||
|
||||
public ErrorMessage Error { get; set; }
|
||||
public Exception Exception { get; set; }
|
||||
|
||||
public string Message => Error?.Error;
|
||||
public string Description => Error?.ErrorDescription ?? Exception?.Message;
|
||||
public string RedirectUri => Error?.RedirectUri;
|
||||
public string RequestId
|
||||
public class ErrorViewModel
|
||||
{
|
||||
get
|
||||
private string _requestId;
|
||||
|
||||
public ErrorMessage Error { get; set; }
|
||||
public Exception Exception { get; set; }
|
||||
|
||||
public string Message => Error?.Error;
|
||||
public string Description => Error?.ErrorDescription ?? Exception?.Message;
|
||||
public string RedirectUri => Error?.RedirectUri;
|
||||
public string RequestId
|
||||
{
|
||||
return Error?.RequestId ?? _requestId;
|
||||
}
|
||||
set
|
||||
{
|
||||
_requestId = value;
|
||||
get
|
||||
{
|
||||
return Error?.RequestId ?? _requestId;
|
||||
}
|
||||
set
|
||||
{
|
||||
_requestId = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Bit.Sso.Models;
|
||||
|
||||
public class RedirectViewModel
|
||||
namespace Bit.Sso.Models
|
||||
{
|
||||
public string RedirectUrl { get; set; }
|
||||
public class RedirectViewModel
|
||||
{
|
||||
public string RedirectUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Bit.Sso.Models;
|
||||
|
||||
public class SamlEnvironment
|
||||
namespace Bit.Sso.Models
|
||||
{
|
||||
public X509Certificate2 SpSigningCertificate { get; set; }
|
||||
public class SamlEnvironment
|
||||
{
|
||||
public X509Certificate2 SpSigningCertificate { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Sso.Models;
|
||||
|
||||
public class SsoPreValidateResponseModel : JsonResult
|
||||
namespace Bit.Sso.Models
|
||||
{
|
||||
public SsoPreValidateResponseModel(string token) : base(new
|
||||
public class SsoPreValidateResponseModel : JsonResult
|
||||
{
|
||||
token
|
||||
})
|
||||
{ }
|
||||
public SsoPreValidateResponseModel(string token) : base(new
|
||||
{
|
||||
token
|
||||
})
|
||||
{ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,32 +2,33 @@
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Bit.Sso;
|
||||
|
||||
public class Program
|
||||
namespace Bit.Sso
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
||||
logging.AddSerilog(hostingContext, e =>
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
var context = e.Properties["SourceContext"].ToString();
|
||||
if (e.Properties.ContainsKey("RequestPath") &&
|
||||
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
|
||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
||||
logging.AddSerilog(hostingContext, e =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return e.Level >= LogEventLevel.Error;
|
||||
}));
|
||||
})
|
||||
.Build()
|
||||
.Run();
|
||||
var context = e.Properties["SourceContext"].ToString();
|
||||
if (e.Properties.ContainsKey("RequestPath") &&
|
||||
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
|
||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return e.Level >= LogEventLevel.Error;
|
||||
}));
|
||||
})
|
||||
.Build()
|
||||
.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,147 +8,148 @@ using IdentityServer4.Extensions;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Sso;
|
||||
|
||||
public class Startup
|
||||
namespace Bit.Sso
|
||||
{
|
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration)
|
||||
public class Startup
|
||||
{
|
||||
Configuration = configuration;
|
||||
Environment = env;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
public IWebHostEnvironment Environment { get; set; }
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Options
|
||||
services.AddOptions();
|
||||
|
||||
// Settings
|
||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
||||
|
||||
// Stripe Billing
|
||||
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
|
||||
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries;
|
||||
|
||||
// Data Protection
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
// Repositories
|
||||
services.AddSqlServerRepositories(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
// Caching
|
||||
services.AddMemoryCache();
|
||||
services.AddDistributedCache(globalSettings);
|
||||
|
||||
// Mvc
|
||||
services.AddControllersWithViews();
|
||||
|
||||
// Cookies
|
||||
if (Environment.IsDevelopment())
|
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<CookiePolicyOptions>(options =>
|
||||
Configuration = configuration;
|
||||
Environment = env;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
public IWebHostEnvironment Environment { get; set; }
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Options
|
||||
services.AddOptions();
|
||||
|
||||
// Settings
|
||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
||||
|
||||
// Stripe Billing
|
||||
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
|
||||
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries;
|
||||
|
||||
// Data Protection
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
// Repositories
|
||||
services.AddSqlServerRepositories(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
// Caching
|
||||
services.AddMemoryCache();
|
||||
services.AddDistributedCache(globalSettings);
|
||||
|
||||
// Mvc
|
||||
services.AddControllersWithViews();
|
||||
|
||||
// Cookies
|
||||
if (Environment.IsDevelopment())
|
||||
{
|
||||
options.MinimumSameSitePolicy = Microsoft.AspNetCore.Http.SameSiteMode.Unspecified;
|
||||
options.OnAppendCookie = ctx =>
|
||||
services.Configure<CookiePolicyOptions>(options =>
|
||||
{
|
||||
ctx.CookieOptions.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Unspecified;
|
||||
};
|
||||
});
|
||||
options.MinimumSameSitePolicy = Microsoft.AspNetCore.Http.SameSiteMode.Unspecified;
|
||||
options.OnAppendCookie = ctx =>
|
||||
{
|
||||
ctx.CookieOptions.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Unspecified;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication
|
||||
services.AddDistributedIdentityServices(globalSettings);
|
||||
services.AddAuthentication()
|
||||
.AddCookie(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
||||
services.AddSsoServices(globalSettings);
|
||||
|
||||
// IdentityServer
|
||||
services.AddSsoIdentityServerServices(Environment, globalSettings);
|
||||
|
||||
// Identity
|
||||
services.AddCustomIdentityServices(globalSettings);
|
||||
|
||||
// Services
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
services.AddCoreLocalizationServices();
|
||||
}
|
||||
|
||||
// Authentication
|
||||
services.AddDistributedIdentityServices(globalSettings);
|
||||
services.AddAuthentication()
|
||||
.AddCookie(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
||||
services.AddSsoServices(globalSettings);
|
||||
|
||||
// IdentityServer
|
||||
services.AddSsoIdentityServerServices(Environment, globalSettings);
|
||||
|
||||
// Identity
|
||||
services.AddCustomIdentityServices(globalSettings);
|
||||
|
||||
// Services
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
services.AddCoreLocalizationServices();
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
if (env.IsDevelopment() || globalSettings.SelfHosted)
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
|
||||
app.UseSerilog(env, appLifetime, globalSettings);
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
{
|
||||
var uri = new Uri(globalSettings.BaseServiceUri.Sso);
|
||||
app.Use(async (ctx, next) =>
|
||||
if (env.IsDevelopment() || globalSettings.SelfHosted)
|
||||
{
|
||||
ctx.SetIdentityServerOrigin($"{uri.Scheme}://{uri.Host}");
|
||||
await next();
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
|
||||
app.UseSerilog(env, appLifetime, globalSettings);
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
{
|
||||
var uri = new Uri(globalSettings.BaseServiceUri.Sso);
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
ctx.SetIdentityServerOrigin($"{uri.Scheme}://{uri.Host}");
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
app.UsePathBase("/sso");
|
||||
app.UseForwardedHeaders(globalSettings);
|
||||
}
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseCookiePolicy();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error");
|
||||
}
|
||||
|
||||
app.UseCoreLocalization();
|
||||
|
||||
// Add static files to the request pipeline.
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Add routing
|
||||
app.UseRouting();
|
||||
|
||||
// Add Cors
|
||||
app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings))
|
||||
.AllowAnyMethod().AllowAnyHeader().AllowCredentials());
|
||||
|
||||
// Add current context
|
||||
app.UseMiddleware<CurrentContextMiddleware>();
|
||||
|
||||
// Add IdentityServer to the request pipeline.
|
||||
app.UseIdentityServer(new IdentityServerMiddlewareOptions
|
||||
{
|
||||
AuthenticationMiddleware = app => app.UseMiddleware<SsoAuthenticationMiddleware>()
|
||||
});
|
||||
|
||||
// Add Mvc stuff
|
||||
app.UseAuthorization();
|
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
|
||||
|
||||
// Log startup
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started.");
|
||||
}
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
app.UsePathBase("/sso");
|
||||
app.UseForwardedHeaders(globalSettings);
|
||||
}
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseCookiePolicy();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error");
|
||||
}
|
||||
|
||||
app.UseCoreLocalization();
|
||||
|
||||
// Add static files to the request pipeline.
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Add routing
|
||||
app.UseRouting();
|
||||
|
||||
// Add Cors
|
||||
app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings))
|
||||
.AllowAnyMethod().AllowAnyHeader().AllowCredentials());
|
||||
|
||||
// Add current context
|
||||
app.UseMiddleware<CurrentContextMiddleware>();
|
||||
|
||||
// Add IdentityServer to the request pipeline.
|
||||
app.UseIdentityServer(new IdentityServerMiddlewareOptions
|
||||
{
|
||||
AuthenticationMiddleware = app => app.UseMiddleware<SsoAuthenticationMiddleware>()
|
||||
});
|
||||
|
||||
// Add Mvc stuff
|
||||
app.UseAuthorization();
|
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
|
||||
|
||||
// Log startup
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class ClaimsExtensions
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
private static readonly Regex _normalizeTextRegEx =
|
||||
new Regex(@"[^a-zA-Z]", RegexOptions.CultureInvariant | RegexOptions.Singleline);
|
||||
|
||||
public static string GetFirstMatch(this IEnumerable<Claim> claims, params string[] possibleNames)
|
||||
public static class ClaimsExtensions
|
||||
{
|
||||
var normalizedClaims = claims.Select(c => (Normalize(c.Type), c.Value)).ToList();
|
||||
private static readonly Regex _normalizeTextRegEx =
|
||||
new Regex(@"[^a-zA-Z]", RegexOptions.CultureInvariant | RegexOptions.Singleline);
|
||||
|
||||
// Order of prescendence is by passed in names
|
||||
foreach (var name in possibleNames.Select(Normalize))
|
||||
public static string GetFirstMatch(this IEnumerable<Claim> claims, params string[] possibleNames)
|
||||
{
|
||||
// Second by order of claims (find claim by name)
|
||||
foreach (var claim in normalizedClaims)
|
||||
var normalizedClaims = claims.Select(c => (Normalize(c.Type), c.Value)).ToList();
|
||||
|
||||
// Order of prescendence is by passed in names
|
||||
foreach (var name in possibleNames.Select(Normalize))
|
||||
{
|
||||
if (Equals(claim.Item1, name))
|
||||
// Second by order of claims (find claim by name)
|
||||
foreach (var claim in normalizedClaims)
|
||||
{
|
||||
return claim.Value;
|
||||
if (Equals(claim.Item1, name))
|
||||
{
|
||||
return claim.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Equals(string text, string compare)
|
||||
{
|
||||
return text == compare ||
|
||||
(string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(compare)) ||
|
||||
string.Equals(Normalize(text), compare, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
private static bool Equals(string text, string compare)
|
||||
{
|
||||
return text;
|
||||
return text == compare ||
|
||||
(string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(compare)) ||
|
||||
string.Equals(Normalize(text), compare, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
return _normalizeTextRegEx.Replace(text, string.Empty);
|
||||
}
|
||||
return _normalizeTextRegEx.Replace(text, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,32 @@ using IdentityServer4.Services;
|
||||
using IdentityServer4.Stores;
|
||||
using IdentityServer4.Validation;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public class DiscoveryResponseGenerator : IdentityServer4.ResponseHandling.DiscoveryResponseGenerator
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public DiscoveryResponseGenerator(
|
||||
IdentityServerOptions options,
|
||||
IResourceStore resourceStore,
|
||||
IKeyMaterialService keys,
|
||||
ExtensionGrantValidator extensionGrants,
|
||||
ISecretsListParser secretParsers,
|
||||
IResourceOwnerPasswordValidator resourceOwnerValidator,
|
||||
ILogger<DiscoveryResponseGenerator> logger,
|
||||
GlobalSettings globalSettings)
|
||||
: base(options, resourceStore, keys, extensionGrants, secretParsers, resourceOwnerValidator, logger)
|
||||
public class DiscoveryResponseGenerator : IdentityServer4.ResponseHandling.DiscoveryResponseGenerator
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public override async Task<Dictionary<string, object>> CreateDiscoveryDocumentAsync(
|
||||
string baseUrl, string issuerUri)
|
||||
{
|
||||
var dict = await base.CreateDiscoveryDocumentAsync(baseUrl, issuerUri);
|
||||
return CoreHelpers.AdjustIdentityServerConfig(dict, _globalSettings.BaseServiceUri.Sso,
|
||||
_globalSettings.BaseServiceUri.InternalSso);
|
||||
public DiscoveryResponseGenerator(
|
||||
IdentityServerOptions options,
|
||||
IResourceStore resourceStore,
|
||||
IKeyMaterialService keys,
|
||||
ExtensionGrantValidator extensionGrants,
|
||||
ISecretsListParser secretParsers,
|
||||
IResourceOwnerPasswordValidator resourceOwnerValidator,
|
||||
ILogger<DiscoveryResponseGenerator> logger,
|
||||
GlobalSettings globalSettings)
|
||||
: base(options, resourceStore, keys, extensionGrants, secretParsers, resourceOwnerValidator, logger)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public override async Task<Dictionary<string, object>> CreateDiscoveryDocumentAsync(
|
||||
string baseUrl, string issuerUri)
|
||||
{
|
||||
var dict = await base.CreateDiscoveryDocumentAsync(baseUrl, issuerUri);
|
||||
return CoreHelpers.AdjustIdentityServerConfig(dict, _globalSettings.BaseServiceUri.Sso,
|
||||
_globalSettings.BaseServiceUri.InternalSso);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,87 +3,88 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public class DynamicAuthenticationScheme : AuthenticationScheme, IDynamicAuthenticationScheme
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
public DynamicAuthenticationScheme(string name, string displayName, Type handlerType,
|
||||
AuthenticationSchemeOptions options)
|
||||
: base(name, displayName, handlerType)
|
||||
public class DynamicAuthenticationScheme : AuthenticationScheme, IDynamicAuthenticationScheme
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
public DynamicAuthenticationScheme(string name, string displayName, Type handlerType,
|
||||
AuthenticationSchemeOptions options, SsoType ssoType)
|
||||
: this(name, displayName, handlerType, options)
|
||||
{
|
||||
SsoType = ssoType;
|
||||
}
|
||||
public DynamicAuthenticationScheme(string name, string displayName, Type handlerType,
|
||||
AuthenticationSchemeOptions options)
|
||||
: base(name, displayName, handlerType)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
public DynamicAuthenticationScheme(string name, string displayName, Type handlerType,
|
||||
AuthenticationSchemeOptions options, SsoType ssoType)
|
||||
: this(name, displayName, handlerType, options)
|
||||
{
|
||||
SsoType = ssoType;
|
||||
}
|
||||
|
||||
public AuthenticationSchemeOptions Options { get; set; }
|
||||
public SsoType SsoType { get; set; }
|
||||
public AuthenticationSchemeOptions Options { get; set; }
|
||||
public SsoType SsoType { get; set; }
|
||||
|
||||
public async Task Validate()
|
||||
{
|
||||
switch (SsoType)
|
||||
public async Task Validate()
|
||||
{
|
||||
case SsoType.OpenIdConnect:
|
||||
await ValidateOpenIdConnectAsync();
|
||||
break;
|
||||
case SsoType.Saml2:
|
||||
ValidateSaml();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSaml()
|
||||
{
|
||||
if (SsoType != SsoType.Saml2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!(Options is Saml2Options samlOptions))
|
||||
{
|
||||
throw new Exception("InvalidAuthenticationOptionsForSaml2SchemeError");
|
||||
}
|
||||
samlOptions.Validate(Name);
|
||||
}
|
||||
|
||||
private async Task ValidateOpenIdConnectAsync()
|
||||
{
|
||||
if (SsoType != SsoType.OpenIdConnect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!(Options is OpenIdConnectOptions oidcOptions))
|
||||
{
|
||||
throw new Exception("InvalidAuthenticationOptionsForOidcSchemeError");
|
||||
}
|
||||
oidcOptions.Validate();
|
||||
if (oidcOptions.Configuration == null)
|
||||
{
|
||||
if (oidcOptions.ConfigurationManager == null)
|
||||
switch (SsoType)
|
||||
{
|
||||
throw new Exception("PostConfigurationNotExecutedError");
|
||||
case SsoType.OpenIdConnect:
|
||||
await ValidateOpenIdConnectAsync();
|
||||
break;
|
||||
case SsoType.Saml2:
|
||||
ValidateSaml();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSaml()
|
||||
{
|
||||
if (SsoType != SsoType.Saml2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!(Options is Saml2Options samlOptions))
|
||||
{
|
||||
throw new Exception("InvalidAuthenticationOptionsForSaml2SchemeError");
|
||||
}
|
||||
samlOptions.Validate(Name);
|
||||
}
|
||||
|
||||
private async Task ValidateOpenIdConnectAsync()
|
||||
{
|
||||
if (SsoType != SsoType.OpenIdConnect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!(Options is OpenIdConnectOptions oidcOptions))
|
||||
{
|
||||
throw new Exception("InvalidAuthenticationOptionsForOidcSchemeError");
|
||||
}
|
||||
oidcOptions.Validate();
|
||||
if (oidcOptions.Configuration == null)
|
||||
{
|
||||
if (oidcOptions.ConfigurationManager == null)
|
||||
{
|
||||
throw new Exception("PostConfigurationNotExecutedError");
|
||||
}
|
||||
if (oidcOptions.Configuration == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
oidcOptions.Configuration = await oidcOptions.ConfigurationManager
|
||||
.GetConfigurationAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception("ReadingOpenIdConnectMetadataFailedError", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oidcOptions.Configuration == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
oidcOptions.Configuration = await oidcOptions.ConfigurationManager
|
||||
.GetConfigurationAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception("ReadingOpenIdConnectMetadataFailedError", ex);
|
||||
}
|
||||
throw new Exception("NoOpenIdConnectMetadataError");
|
||||
}
|
||||
}
|
||||
if (oidcOptions.Configuration == null)
|
||||
{
|
||||
throw new Exception("NoOpenIdConnectMetadataError");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,440 +18,441 @@ using Sustainsys.Saml2.AspNetCore2;
|
||||
using Sustainsys.Saml2.Configuration;
|
||||
using Sustainsys.Saml2.Saml2P;
|
||||
|
||||
namespace Bit.Core.Business.Sso;
|
||||
|
||||
public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
|
||||
namespace Bit.Core.Business.Sso
|
||||
{
|
||||
private readonly IPostConfigureOptions<OpenIdConnectOptions> _oidcPostConfigureOptions;
|
||||
private readonly IExtendedOptionsMonitorCache<OpenIdConnectOptions> _extendedOidcOptionsMonitorCache;
|
||||
private readonly IPostConfigureOptions<Saml2Options> _saml2PostConfigureOptions;
|
||||
private readonly IExtendedOptionsMonitorCache<Saml2Options> _extendedSaml2OptionsMonitorCache;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ILogger _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly SamlEnvironment _samlEnvironment;
|
||||
private readonly TimeSpan _schemeCacheLifetime;
|
||||
private readonly Dictionary<string, DynamicAuthenticationScheme> _cachedSchemes;
|
||||
private readonly Dictionary<string, DynamicAuthenticationScheme> _cachedHandlerSchemes;
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
private DateTime? _lastSchemeLoad;
|
||||
private IEnumerable<DynamicAuthenticationScheme> _schemesCopy = Array.Empty<DynamicAuthenticationScheme>();
|
||||
private IEnumerable<DynamicAuthenticationScheme> _handlerSchemesCopy = Array.Empty<DynamicAuthenticationScheme>();
|
||||
|
||||
public DynamicAuthenticationSchemeProvider(
|
||||
IOptions<AuthenticationOptions> options,
|
||||
IPostConfigureOptions<OpenIdConnectOptions> oidcPostConfigureOptions,
|
||||
IOptionsMonitorCache<OpenIdConnectOptions> oidcOptionsMonitorCache,
|
||||
IPostConfigureOptions<Saml2Options> saml2PostConfigureOptions,
|
||||
IOptionsMonitorCache<Saml2Options> saml2OptionsMonitorCache,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ILogger<DynamicAuthenticationSchemeProvider> logger,
|
||||
GlobalSettings globalSettings,
|
||||
SamlEnvironment samlEnvironment,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
: base(options)
|
||||
public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
|
||||
{
|
||||
_oidcPostConfigureOptions = oidcPostConfigureOptions;
|
||||
_extendedOidcOptionsMonitorCache = oidcOptionsMonitorCache as
|
||||
IExtendedOptionsMonitorCache<OpenIdConnectOptions>;
|
||||
if (_extendedOidcOptionsMonitorCache == null)
|
||||
private readonly IPostConfigureOptions<OpenIdConnectOptions> _oidcPostConfigureOptions;
|
||||
private readonly IExtendedOptionsMonitorCache<OpenIdConnectOptions> _extendedOidcOptionsMonitorCache;
|
||||
private readonly IPostConfigureOptions<Saml2Options> _saml2PostConfigureOptions;
|
||||
private readonly IExtendedOptionsMonitorCache<Saml2Options> _extendedSaml2OptionsMonitorCache;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ILogger _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly SamlEnvironment _samlEnvironment;
|
||||
private readonly TimeSpan _schemeCacheLifetime;
|
||||
private readonly Dictionary<string, DynamicAuthenticationScheme> _cachedSchemes;
|
||||
private readonly Dictionary<string, DynamicAuthenticationScheme> _cachedHandlerSchemes;
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
private DateTime? _lastSchemeLoad;
|
||||
private IEnumerable<DynamicAuthenticationScheme> _schemesCopy = Array.Empty<DynamicAuthenticationScheme>();
|
||||
private IEnumerable<DynamicAuthenticationScheme> _handlerSchemesCopy = Array.Empty<DynamicAuthenticationScheme>();
|
||||
|
||||
public DynamicAuthenticationSchemeProvider(
|
||||
IOptions<AuthenticationOptions> options,
|
||||
IPostConfigureOptions<OpenIdConnectOptions> oidcPostConfigureOptions,
|
||||
IOptionsMonitorCache<OpenIdConnectOptions> oidcOptionsMonitorCache,
|
||||
IPostConfigureOptions<Saml2Options> saml2PostConfigureOptions,
|
||||
IOptionsMonitorCache<Saml2Options> saml2OptionsMonitorCache,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ILogger<DynamicAuthenticationSchemeProvider> logger,
|
||||
GlobalSettings globalSettings,
|
||||
SamlEnvironment samlEnvironment,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
: base(options)
|
||||
{
|
||||
throw new ArgumentNullException("_extendedOidcOptionsMonitorCache could not be resolved.");
|
||||
_oidcPostConfigureOptions = oidcPostConfigureOptions;
|
||||
_extendedOidcOptionsMonitorCache = oidcOptionsMonitorCache as
|
||||
IExtendedOptionsMonitorCache<OpenIdConnectOptions>;
|
||||
if (_extendedOidcOptionsMonitorCache == null)
|
||||
{
|
||||
throw new ArgumentNullException("_extendedOidcOptionsMonitorCache could not be resolved.");
|
||||
}
|
||||
|
||||
_saml2PostConfigureOptions = saml2PostConfigureOptions;
|
||||
_extendedSaml2OptionsMonitorCache = saml2OptionsMonitorCache as
|
||||
IExtendedOptionsMonitorCache<Saml2Options>;
|
||||
if (_extendedSaml2OptionsMonitorCache == null)
|
||||
{
|
||||
throw new ArgumentNullException("_extendedSaml2OptionsMonitorCache could not be resolved.");
|
||||
}
|
||||
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_schemeCacheLifetime = TimeSpan.FromSeconds(_globalSettings.Sso?.CacheLifetimeInSeconds ?? 30);
|
||||
_samlEnvironment = samlEnvironment;
|
||||
_cachedSchemes = new Dictionary<string, DynamicAuthenticationScheme>();
|
||||
_cachedHandlerSchemes = new Dictionary<string, DynamicAuthenticationScheme>();
|
||||
_semaphore = new SemaphoreSlim(1);
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
}
|
||||
|
||||
_saml2PostConfigureOptions = saml2PostConfigureOptions;
|
||||
_extendedSaml2OptionsMonitorCache = saml2OptionsMonitorCache as
|
||||
IExtendedOptionsMonitorCache<Saml2Options>;
|
||||
if (_extendedSaml2OptionsMonitorCache == null)
|
||||
private bool CacheIsValid
|
||||
{
|
||||
throw new ArgumentNullException("_extendedSaml2OptionsMonitorCache could not be resolved.");
|
||||
get => _lastSchemeLoad.HasValue
|
||||
&& _lastSchemeLoad.Value.Add(_schemeCacheLifetime) >= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_schemeCacheLifetime = TimeSpan.FromSeconds(_globalSettings.Sso?.CacheLifetimeInSeconds ?? 30);
|
||||
_samlEnvironment = samlEnvironment;
|
||||
_cachedSchemes = new Dictionary<string, DynamicAuthenticationScheme>();
|
||||
_cachedHandlerSchemes = new Dictionary<string, DynamicAuthenticationScheme>();
|
||||
_semaphore = new SemaphoreSlim(1);
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
}
|
||||
|
||||
private bool CacheIsValid
|
||||
{
|
||||
get => _lastSchemeLoad.HasValue
|
||||
&& _lastSchemeLoad.Value.Add(_schemeCacheLifetime) >= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationScheme> GetSchemeAsync(string name)
|
||||
{
|
||||
var scheme = await base.GetSchemeAsync(name);
|
||||
if (scheme != null)
|
||||
public override async Task<AuthenticationScheme> GetSchemeAsync(string name)
|
||||
{
|
||||
return scheme;
|
||||
var scheme = await base.GetSchemeAsync(name);
|
||||
if (scheme != null)
|
||||
{
|
||||
return scheme;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dynamicScheme = await GetDynamicSchemeAsync(name);
|
||||
return dynamicScheme;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unable to load a dynamic authentication scheme for '{0}'", name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
public override async Task<IEnumerable<AuthenticationScheme>> GetAllSchemesAsync()
|
||||
{
|
||||
var dynamicScheme = await GetDynamicSchemeAsync(name);
|
||||
return dynamicScheme;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unable to load a dynamic authentication scheme for '{0}'", name);
|
||||
var existingSchemes = await base.GetAllSchemesAsync();
|
||||
var schemes = new List<AuthenticationScheme>();
|
||||
schemes.AddRange(existingSchemes);
|
||||
|
||||
await LoadAllDynamicSchemesIntoCacheAsync();
|
||||
schemes.AddRange(_schemesCopy);
|
||||
|
||||
return schemes.ToArray();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<AuthenticationScheme>> GetAllSchemesAsync()
|
||||
{
|
||||
var existingSchemes = await base.GetAllSchemesAsync();
|
||||
var schemes = new List<AuthenticationScheme>();
|
||||
schemes.AddRange(existingSchemes);
|
||||
|
||||
await LoadAllDynamicSchemesIntoCacheAsync();
|
||||
schemes.AddRange(_schemesCopy);
|
||||
|
||||
return schemes.ToArray();
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<AuthenticationScheme>> GetRequestHandlerSchemesAsync()
|
||||
{
|
||||
var existingSchemes = await base.GetRequestHandlerSchemesAsync();
|
||||
var schemes = new List<AuthenticationScheme>();
|
||||
schemes.AddRange(existingSchemes);
|
||||
|
||||
await LoadAllDynamicSchemesIntoCacheAsync();
|
||||
schemes.AddRange(_handlerSchemesCopy);
|
||||
|
||||
return schemes.ToArray();
|
||||
}
|
||||
|
||||
private async Task LoadAllDynamicSchemesIntoCacheAsync()
|
||||
{
|
||||
if (CacheIsValid)
|
||||
public override async Task<IEnumerable<AuthenticationScheme>> GetRequestHandlerSchemesAsync()
|
||||
{
|
||||
// Our cache hasn't expired or been invalidated, ignore request
|
||||
return;
|
||||
var existingSchemes = await base.GetRequestHandlerSchemesAsync();
|
||||
var schemes = new List<AuthenticationScheme>();
|
||||
schemes.AddRange(existingSchemes);
|
||||
|
||||
await LoadAllDynamicSchemesIntoCacheAsync();
|
||||
schemes.AddRange(_handlerSchemesCopy);
|
||||
|
||||
return schemes.ToArray();
|
||||
}
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
|
||||
private async Task LoadAllDynamicSchemesIntoCacheAsync()
|
||||
{
|
||||
if (CacheIsValid)
|
||||
{
|
||||
// Just in case (double-checked locking pattern)
|
||||
// Our cache hasn't expired or been invalidated, ignore request
|
||||
return;
|
||||
}
|
||||
|
||||
// Save time just in case the following operation takes longer
|
||||
var now = DateTime.UtcNow;
|
||||
var newSchemes = await _ssoConfigRepository.GetManyByRevisionNotBeforeDate(_lastSchemeLoad);
|
||||
|
||||
foreach (var config in newSchemes)
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
DynamicAuthenticationScheme scheme;
|
||||
try
|
||||
if (CacheIsValid)
|
||||
{
|
||||
scheme = GetSchemeFromSsoConfig(config);
|
||||
// Just in case (double-checked locking pattern)
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// Save time just in case the following operation takes longer
|
||||
var now = DateTime.UtcNow;
|
||||
var newSchemes = await _ssoConfigRepository.GetManyByRevisionNotBeforeDate(_lastSchemeLoad);
|
||||
|
||||
foreach (var config in newSchemes)
|
||||
{
|
||||
_logger.LogError(ex, "Error converting configuration to scheme for '{0}'", config.Id);
|
||||
continue;
|
||||
DynamicAuthenticationScheme scheme;
|
||||
try
|
||||
{
|
||||
scheme = GetSchemeFromSsoConfig(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error converting configuration to scheme for '{0}'", config.Id);
|
||||
continue;
|
||||
}
|
||||
if (scheme == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SetSchemeInCache(scheme);
|
||||
}
|
||||
if (scheme == null)
|
||||
|
||||
if (newSchemes.Any())
|
||||
{
|
||||
continue;
|
||||
// Maintain "safe" copy for use in enumeration routines
|
||||
_schemesCopy = _cachedSchemes.Values.ToArray();
|
||||
_handlerSchemesCopy = _cachedHandlerSchemes.Values.ToArray();
|
||||
}
|
||||
SetSchemeInCache(scheme);
|
||||
_lastSchemeLoad = now;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private DynamicAuthenticationScheme SetSchemeInCache(DynamicAuthenticationScheme scheme)
|
||||
{
|
||||
if (!PostConfigureDynamicScheme(scheme))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
_cachedSchemes[scheme.Name] = scheme;
|
||||
if (typeof(IAuthenticationRequestHandler).IsAssignableFrom(scheme.HandlerType))
|
||||
{
|
||||
_cachedHandlerSchemes[scheme.Name] = scheme;
|
||||
}
|
||||
return scheme;
|
||||
}
|
||||
|
||||
private async Task<DynamicAuthenticationScheme> GetDynamicSchemeAsync(string name)
|
||||
{
|
||||
if (_cachedSchemes.TryGetValue(name, out var cachedScheme))
|
||||
{
|
||||
return cachedScheme;
|
||||
}
|
||||
|
||||
if (newSchemes.Any())
|
||||
{
|
||||
// Maintain "safe" copy for use in enumeration routines
|
||||
_schemesCopy = _cachedSchemes.Values.ToArray();
|
||||
_handlerSchemesCopy = _cachedHandlerSchemes.Values.ToArray();
|
||||
}
|
||||
_lastSchemeLoad = now;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private DynamicAuthenticationScheme SetSchemeInCache(DynamicAuthenticationScheme scheme)
|
||||
{
|
||||
if (!PostConfigureDynamicScheme(scheme))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
_cachedSchemes[scheme.Name] = scheme;
|
||||
if (typeof(IAuthenticationRequestHandler).IsAssignableFrom(scheme.HandlerType))
|
||||
{
|
||||
_cachedHandlerSchemes[scheme.Name] = scheme;
|
||||
}
|
||||
return scheme;
|
||||
}
|
||||
|
||||
private async Task<DynamicAuthenticationScheme> GetDynamicSchemeAsync(string name)
|
||||
{
|
||||
if (_cachedSchemes.TryGetValue(name, out var cachedScheme))
|
||||
{
|
||||
return cachedScheme;
|
||||
}
|
||||
|
||||
var scheme = await GetSchemeFromSsoConfigAsync(name);
|
||||
if (scheme == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
scheme = SetSchemeInCache(scheme);
|
||||
var scheme = await GetSchemeFromSsoConfigAsync(name);
|
||||
if (scheme == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof(IAuthenticationRequestHandler).IsAssignableFrom(scheme.HandlerType))
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
_handlerSchemesCopy = _cachedHandlerSchemes.Values.ToArray();
|
||||
scheme = SetSchemeInCache(scheme);
|
||||
if (scheme == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof(IAuthenticationRequestHandler).IsAssignableFrom(scheme.HandlerType))
|
||||
{
|
||||
_handlerSchemesCopy = _cachedHandlerSchemes.Values.ToArray();
|
||||
}
|
||||
_schemesCopy = _cachedSchemes.Values.ToArray();
|
||||
}
|
||||
_schemesCopy = _cachedSchemes.Values.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Note: _lastSchemeLoad is not set here, this is a one-off
|
||||
// and should not impact loading further cache updates
|
||||
_semaphore.Release();
|
||||
}
|
||||
return scheme;
|
||||
}
|
||||
|
||||
private bool PostConfigureDynamicScheme(DynamicAuthenticationScheme scheme)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (scheme.SsoType == SsoType.OpenIdConnect && scheme.Options is OpenIdConnectOptions oidcOptions)
|
||||
finally
|
||||
{
|
||||
_oidcPostConfigureOptions.PostConfigure(scheme.Name, oidcOptions);
|
||||
_extendedOidcOptionsMonitorCache.AddOrUpdate(scheme.Name, oidcOptions);
|
||||
// Note: _lastSchemeLoad is not set here, this is a one-off
|
||||
// and should not impact loading further cache updates
|
||||
_semaphore.Release();
|
||||
}
|
||||
else if (scheme.SsoType == SsoType.Saml2 && scheme.Options is Saml2Options saml2Options)
|
||||
return scheme;
|
||||
}
|
||||
|
||||
private bool PostConfigureDynamicScheme(DynamicAuthenticationScheme scheme)
|
||||
{
|
||||
try
|
||||
{
|
||||
_saml2PostConfigureOptions.PostConfigure(scheme.Name, saml2Options);
|
||||
_extendedSaml2OptionsMonitorCache.AddOrUpdate(scheme.Name, saml2Options);
|
||||
if (scheme.SsoType == SsoType.OpenIdConnect && scheme.Options is OpenIdConnectOptions oidcOptions)
|
||||
{
|
||||
_oidcPostConfigureOptions.PostConfigure(scheme.Name, oidcOptions);
|
||||
_extendedOidcOptionsMonitorCache.AddOrUpdate(scheme.Name, oidcOptions);
|
||||
}
|
||||
else if (scheme.SsoType == SsoType.Saml2 && scheme.Options is Saml2Options saml2Options)
|
||||
{
|
||||
_saml2PostConfigureOptions.PostConfigure(scheme.Name, saml2Options);
|
||||
_extendedSaml2OptionsMonitorCache.AddOrUpdate(scheme.Name, saml2Options);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error performing post configuration for '{0}' ({1})",
|
||||
scheme.Name, scheme.DisplayName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private DynamicAuthenticationScheme GetSchemeFromSsoConfig(SsoConfig config)
|
||||
{
|
||||
var data = config.GetData();
|
||||
return data.ConfigType switch
|
||||
{
|
||||
SsoType.OpenIdConnect => GetOidcAuthenticationScheme(config.OrganizationId.ToString(), data),
|
||||
SsoType.Saml2 => GetSaml2AuthenticationScheme(config.OrganizationId.ToString(), data),
|
||||
_ => throw new Exception($"SSO Config Type, '{data.ConfigType}', not supported"),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<DynamicAuthenticationScheme> GetSchemeFromSsoConfigAsync(string name)
|
||||
{
|
||||
if (!Guid.TryParse(name, out var organizationId))
|
||||
{
|
||||
_logger.LogWarning("Could not determine organization id from name, '{0}'", name);
|
||||
return null;
|
||||
}
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organizationId);
|
||||
if (ssoConfig == null || !ssoConfig.Enabled)
|
||||
{
|
||||
_logger.LogWarning("Could not find SSO config or config was not enabled for '{0}'", name);
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetSchemeFromSsoConfig(ssoConfig);
|
||||
}
|
||||
|
||||
private DynamicAuthenticationScheme GetOidcAuthenticationScheme(string name, SsoConfigurationData config)
|
||||
{
|
||||
var oidcOptions = new OpenIdConnectOptions
|
||||
{
|
||||
Authority = config.Authority,
|
||||
ClientId = config.ClientId,
|
||||
ClientSecret = config.ClientSecret,
|
||||
ResponseType = "code",
|
||||
ResponseMode = "form_post",
|
||||
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
|
||||
SignOutScheme = IdentityServerConstants.SignoutScheme,
|
||||
SaveTokens = false, // reduce overall request size
|
||||
TokenValidationParameters = new TokenValidationParameters
|
||||
catch (Exception ex)
|
||||
{
|
||||
NameClaimType = JwtClaimTypes.Name,
|
||||
RoleClaimType = JwtClaimTypes.Role,
|
||||
},
|
||||
CallbackPath = SsoConfigurationData.BuildCallbackPath(),
|
||||
SignedOutCallbackPath = SsoConfigurationData.BuildSignedOutCallbackPath(),
|
||||
MetadataAddress = config.MetadataAddress,
|
||||
// Prevents URLs that go beyond 1024 characters which may break for some servers
|
||||
AuthenticationMethod = config.RedirectBehavior,
|
||||
GetClaimsFromUserInfoEndpoint = config.GetClaimsFromUserInfoEndpoint,
|
||||
};
|
||||
oidcOptions.Scope
|
||||
.AddIfNotExists(OpenIdConnectScopes.OpenId)
|
||||
.AddIfNotExists(OpenIdConnectScopes.Email)
|
||||
.AddIfNotExists(OpenIdConnectScopes.Profile);
|
||||
foreach (var scope in config.GetAdditionalScopes())
|
||||
{
|
||||
oidcOptions.Scope.AddIfNotExists(scope);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.ExpectedReturnAcrValue))
|
||||
{
|
||||
oidcOptions.Scope.AddIfNotExists(OpenIdConnectScopes.Acr);
|
||||
_logger.LogError(ex, "Error performing post configuration for '{0}' ({1})",
|
||||
scheme.Name, scheme.DisplayName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
oidcOptions.StateDataFormat = new DistributedCacheStateDataFormatter(_httpContextAccessor, name);
|
||||
|
||||
// see: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (acr_values)
|
||||
if (!string.IsNullOrWhiteSpace(config.AcrValues))
|
||||
private DynamicAuthenticationScheme GetSchemeFromSsoConfig(SsoConfig config)
|
||||
{
|
||||
oidcOptions.Events ??= new OpenIdConnectEvents();
|
||||
oidcOptions.Events.OnRedirectToIdentityProvider = ctx =>
|
||||
var data = config.GetData();
|
||||
return data.ConfigType switch
|
||||
{
|
||||
ctx.ProtocolMessage.AcrValues = config.AcrValues;
|
||||
return Task.CompletedTask;
|
||||
SsoType.OpenIdConnect => GetOidcAuthenticationScheme(config.OrganizationId.ToString(), data),
|
||||
SsoType.Saml2 => GetSaml2AuthenticationScheme(config.OrganizationId.ToString(), data),
|
||||
_ => throw new Exception($"SSO Config Type, '{data.ConfigType}', not supported"),
|
||||
};
|
||||
}
|
||||
|
||||
return new DynamicAuthenticationScheme(name, name, typeof(OpenIdConnectHandler),
|
||||
oidcOptions, SsoType.OpenIdConnect);
|
||||
}
|
||||
|
||||
private DynamicAuthenticationScheme GetSaml2AuthenticationScheme(string name, SsoConfigurationData config)
|
||||
{
|
||||
if (_samlEnvironment == null)
|
||||
private async Task<DynamicAuthenticationScheme> GetSchemeFromSsoConfigAsync(string name)
|
||||
{
|
||||
throw new Exception($"SSO SAML2 Service Provider profile is missing for {name}");
|
||||
if (!Guid.TryParse(name, out var organizationId))
|
||||
{
|
||||
_logger.LogWarning("Could not determine organization id from name, '{0}'", name);
|
||||
return null;
|
||||
}
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organizationId);
|
||||
if (ssoConfig == null || !ssoConfig.Enabled)
|
||||
{
|
||||
_logger.LogWarning("Could not find SSO config or config was not enabled for '{0}'", name);
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetSchemeFromSsoConfig(ssoConfig);
|
||||
}
|
||||
|
||||
var spEntityId = new Sustainsys.Saml2.Metadata.EntityId(
|
||||
SsoConfigurationData.BuildSaml2ModulePath(_globalSettings.BaseServiceUri.Sso));
|
||||
bool? allowCreate = null;
|
||||
if (config.SpNameIdFormat != Saml2NameIdFormat.Transient)
|
||||
private DynamicAuthenticationScheme GetOidcAuthenticationScheme(string name, SsoConfigurationData config)
|
||||
{
|
||||
allowCreate = true;
|
||||
}
|
||||
var spOptions = new SPOptions
|
||||
{
|
||||
EntityId = spEntityId,
|
||||
ModulePath = SsoConfigurationData.BuildSaml2ModulePath(null, name),
|
||||
NameIdPolicy = new Saml2NameIdPolicy(allowCreate, GetNameIdFormat(config.SpNameIdFormat)),
|
||||
WantAssertionsSigned = config.SpWantAssertionsSigned,
|
||||
AuthenticateRequestSigningBehavior = GetSigningBehavior(config.SpSigningBehavior),
|
||||
ValidateCertificates = config.SpValidateCertificates,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(config.SpMinIncomingSigningAlgorithm))
|
||||
{
|
||||
spOptions.MinIncomingSigningAlgorithm = config.SpMinIncomingSigningAlgorithm;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.SpOutboundSigningAlgorithm))
|
||||
{
|
||||
spOptions.OutboundSigningAlgorithm = config.SpOutboundSigningAlgorithm;
|
||||
}
|
||||
if (_samlEnvironment.SpSigningCertificate != null)
|
||||
{
|
||||
spOptions.ServiceCertificates.Add(_samlEnvironment.SpSigningCertificate);
|
||||
var oidcOptions = new OpenIdConnectOptions
|
||||
{
|
||||
Authority = config.Authority,
|
||||
ClientId = config.ClientId,
|
||||
ClientSecret = config.ClientSecret,
|
||||
ResponseType = "code",
|
||||
ResponseMode = "form_post",
|
||||
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
|
||||
SignOutScheme = IdentityServerConstants.SignoutScheme,
|
||||
SaveTokens = false, // reduce overall request size
|
||||
TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
NameClaimType = JwtClaimTypes.Name,
|
||||
RoleClaimType = JwtClaimTypes.Role,
|
||||
},
|
||||
CallbackPath = SsoConfigurationData.BuildCallbackPath(),
|
||||
SignedOutCallbackPath = SsoConfigurationData.BuildSignedOutCallbackPath(),
|
||||
MetadataAddress = config.MetadataAddress,
|
||||
// Prevents URLs that go beyond 1024 characters which may break for some servers
|
||||
AuthenticationMethod = config.RedirectBehavior,
|
||||
GetClaimsFromUserInfoEndpoint = config.GetClaimsFromUserInfoEndpoint,
|
||||
};
|
||||
oidcOptions.Scope
|
||||
.AddIfNotExists(OpenIdConnectScopes.OpenId)
|
||||
.AddIfNotExists(OpenIdConnectScopes.Email)
|
||||
.AddIfNotExists(OpenIdConnectScopes.Profile);
|
||||
foreach (var scope in config.GetAdditionalScopes())
|
||||
{
|
||||
oidcOptions.Scope.AddIfNotExists(scope);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.ExpectedReturnAcrValue))
|
||||
{
|
||||
oidcOptions.Scope.AddIfNotExists(OpenIdConnectScopes.Acr);
|
||||
}
|
||||
|
||||
oidcOptions.StateDataFormat = new DistributedCacheStateDataFormatter(_httpContextAccessor, name);
|
||||
|
||||
// see: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (acr_values)
|
||||
if (!string.IsNullOrWhiteSpace(config.AcrValues))
|
||||
{
|
||||
oidcOptions.Events ??= new OpenIdConnectEvents();
|
||||
oidcOptions.Events.OnRedirectToIdentityProvider = ctx =>
|
||||
{
|
||||
ctx.ProtocolMessage.AcrValues = config.AcrValues;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
return new DynamicAuthenticationScheme(name, name, typeof(OpenIdConnectHandler),
|
||||
oidcOptions, SsoType.OpenIdConnect);
|
||||
}
|
||||
|
||||
var idpEntityId = new Sustainsys.Saml2.Metadata.EntityId(config.IdpEntityId);
|
||||
var idp = new Sustainsys.Saml2.IdentityProvider(idpEntityId, spOptions)
|
||||
private DynamicAuthenticationScheme GetSaml2AuthenticationScheme(string name, SsoConfigurationData config)
|
||||
{
|
||||
Binding = GetBindingType(config.IdpBindingType),
|
||||
AllowUnsolicitedAuthnResponse = config.IdpAllowUnsolicitedAuthnResponse,
|
||||
DisableOutboundLogoutRequests = config.IdpDisableOutboundLogoutRequests,
|
||||
WantAuthnRequestsSigned = config.IdpWantAuthnRequestsSigned,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpSingleSignOnServiceUrl))
|
||||
{
|
||||
idp.SingleSignOnServiceUrl = new Uri(config.IdpSingleSignOnServiceUrl);
|
||||
if (_samlEnvironment == null)
|
||||
{
|
||||
throw new Exception($"SSO SAML2 Service Provider profile is missing for {name}");
|
||||
}
|
||||
|
||||
var spEntityId = new Sustainsys.Saml2.Metadata.EntityId(
|
||||
SsoConfigurationData.BuildSaml2ModulePath(_globalSettings.BaseServiceUri.Sso));
|
||||
bool? allowCreate = null;
|
||||
if (config.SpNameIdFormat != Saml2NameIdFormat.Transient)
|
||||
{
|
||||
allowCreate = true;
|
||||
}
|
||||
var spOptions = new SPOptions
|
||||
{
|
||||
EntityId = spEntityId,
|
||||
ModulePath = SsoConfigurationData.BuildSaml2ModulePath(null, name),
|
||||
NameIdPolicy = new Saml2NameIdPolicy(allowCreate, GetNameIdFormat(config.SpNameIdFormat)),
|
||||
WantAssertionsSigned = config.SpWantAssertionsSigned,
|
||||
AuthenticateRequestSigningBehavior = GetSigningBehavior(config.SpSigningBehavior),
|
||||
ValidateCertificates = config.SpValidateCertificates,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(config.SpMinIncomingSigningAlgorithm))
|
||||
{
|
||||
spOptions.MinIncomingSigningAlgorithm = config.SpMinIncomingSigningAlgorithm;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.SpOutboundSigningAlgorithm))
|
||||
{
|
||||
spOptions.OutboundSigningAlgorithm = config.SpOutboundSigningAlgorithm;
|
||||
}
|
||||
if (_samlEnvironment.SpSigningCertificate != null)
|
||||
{
|
||||
spOptions.ServiceCertificates.Add(_samlEnvironment.SpSigningCertificate);
|
||||
}
|
||||
|
||||
var idpEntityId = new Sustainsys.Saml2.Metadata.EntityId(config.IdpEntityId);
|
||||
var idp = new Sustainsys.Saml2.IdentityProvider(idpEntityId, spOptions)
|
||||
{
|
||||
Binding = GetBindingType(config.IdpBindingType),
|
||||
AllowUnsolicitedAuthnResponse = config.IdpAllowUnsolicitedAuthnResponse,
|
||||
DisableOutboundLogoutRequests = config.IdpDisableOutboundLogoutRequests,
|
||||
WantAuthnRequestsSigned = config.IdpWantAuthnRequestsSigned,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpSingleSignOnServiceUrl))
|
||||
{
|
||||
idp.SingleSignOnServiceUrl = new Uri(config.IdpSingleSignOnServiceUrl);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpSingleLogoutServiceUrl))
|
||||
{
|
||||
idp.SingleLogoutServiceUrl = new Uri(config.IdpSingleLogoutServiceUrl);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpOutboundSigningAlgorithm))
|
||||
{
|
||||
idp.OutboundSigningAlgorithm = config.IdpOutboundSigningAlgorithm;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpX509PublicCert))
|
||||
{
|
||||
var cert = CoreHelpers.Base64UrlDecode(config.IdpX509PublicCert);
|
||||
idp.SigningKeys.AddConfiguredKey(new X509Certificate2(cert));
|
||||
}
|
||||
idp.ArtifactResolutionServiceUrls.Clear();
|
||||
// This must happen last since it calls Validate() internally.
|
||||
idp.LoadMetadata = false;
|
||||
|
||||
var options = new Saml2Options
|
||||
{
|
||||
SPOptions = spOptions,
|
||||
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
|
||||
SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme,
|
||||
CookieManager = new IdentityServer.DistributedCacheCookieManager(),
|
||||
};
|
||||
options.IdentityProviders.Add(idp);
|
||||
|
||||
return new DynamicAuthenticationScheme(name, name, typeof(Saml2Handler), options, SsoType.Saml2);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpSingleLogoutServiceUrl))
|
||||
|
||||
private NameIdFormat GetNameIdFormat(Saml2NameIdFormat format)
|
||||
{
|
||||
idp.SingleLogoutServiceUrl = new Uri(config.IdpSingleLogoutServiceUrl);
|
||||
return format switch
|
||||
{
|
||||
Saml2NameIdFormat.Unspecified => NameIdFormat.Unspecified,
|
||||
Saml2NameIdFormat.EmailAddress => NameIdFormat.EmailAddress,
|
||||
Saml2NameIdFormat.X509SubjectName => NameIdFormat.X509SubjectName,
|
||||
Saml2NameIdFormat.WindowsDomainQualifiedName => NameIdFormat.WindowsDomainQualifiedName,
|
||||
Saml2NameIdFormat.KerberosPrincipalName => NameIdFormat.KerberosPrincipalName,
|
||||
Saml2NameIdFormat.EntityIdentifier => NameIdFormat.EntityIdentifier,
|
||||
Saml2NameIdFormat.Persistent => NameIdFormat.Persistent,
|
||||
Saml2NameIdFormat.Transient => NameIdFormat.Transient,
|
||||
_ => NameIdFormat.NotConfigured,
|
||||
};
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpOutboundSigningAlgorithm))
|
||||
|
||||
private SigningBehavior GetSigningBehavior(Saml2SigningBehavior behavior)
|
||||
{
|
||||
idp.OutboundSigningAlgorithm = config.IdpOutboundSigningAlgorithm;
|
||||
return behavior switch
|
||||
{
|
||||
Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned => SigningBehavior.IfIdpWantAuthnRequestsSigned,
|
||||
Saml2SigningBehavior.Always => SigningBehavior.Always,
|
||||
Saml2SigningBehavior.Never => SigningBehavior.Never,
|
||||
_ => SigningBehavior.IfIdpWantAuthnRequestsSigned,
|
||||
};
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(config.IdpX509PublicCert))
|
||||
|
||||
private Sustainsys.Saml2.WebSso.Saml2BindingType GetBindingType(Saml2BindingType bindingType)
|
||||
{
|
||||
var cert = CoreHelpers.Base64UrlDecode(config.IdpX509PublicCert);
|
||||
idp.SigningKeys.AddConfiguredKey(new X509Certificate2(cert));
|
||||
return bindingType switch
|
||||
{
|
||||
Saml2BindingType.HttpRedirect => Sustainsys.Saml2.WebSso.Saml2BindingType.HttpRedirect,
|
||||
Saml2BindingType.HttpPost => Sustainsys.Saml2.WebSso.Saml2BindingType.HttpPost,
|
||||
_ => Sustainsys.Saml2.WebSso.Saml2BindingType.HttpPost,
|
||||
};
|
||||
}
|
||||
idp.ArtifactResolutionServiceUrls.Clear();
|
||||
// This must happen last since it calls Validate() internally.
|
||||
idp.LoadMetadata = false;
|
||||
|
||||
var options = new Saml2Options
|
||||
{
|
||||
SPOptions = spOptions,
|
||||
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
|
||||
SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme,
|
||||
CookieManager = new IdentityServer.DistributedCacheCookieManager(),
|
||||
};
|
||||
options.IdentityProviders.Add(idp);
|
||||
|
||||
return new DynamicAuthenticationScheme(name, name, typeof(Saml2Handler), options, SsoType.Saml2);
|
||||
}
|
||||
|
||||
private NameIdFormat GetNameIdFormat(Saml2NameIdFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
Saml2NameIdFormat.Unspecified => NameIdFormat.Unspecified,
|
||||
Saml2NameIdFormat.EmailAddress => NameIdFormat.EmailAddress,
|
||||
Saml2NameIdFormat.X509SubjectName => NameIdFormat.X509SubjectName,
|
||||
Saml2NameIdFormat.WindowsDomainQualifiedName => NameIdFormat.WindowsDomainQualifiedName,
|
||||
Saml2NameIdFormat.KerberosPrincipalName => NameIdFormat.KerberosPrincipalName,
|
||||
Saml2NameIdFormat.EntityIdentifier => NameIdFormat.EntityIdentifier,
|
||||
Saml2NameIdFormat.Persistent => NameIdFormat.Persistent,
|
||||
Saml2NameIdFormat.Transient => NameIdFormat.Transient,
|
||||
_ => NameIdFormat.NotConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
private SigningBehavior GetSigningBehavior(Saml2SigningBehavior behavior)
|
||||
{
|
||||
return behavior switch
|
||||
{
|
||||
Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned => SigningBehavior.IfIdpWantAuthnRequestsSigned,
|
||||
Saml2SigningBehavior.Always => SigningBehavior.Always,
|
||||
Saml2SigningBehavior.Never => SigningBehavior.Never,
|
||||
_ => SigningBehavior.IfIdpWantAuthnRequestsSigned,
|
||||
};
|
||||
}
|
||||
|
||||
private Sustainsys.Saml2.WebSso.Saml2BindingType GetBindingType(Saml2BindingType bindingType)
|
||||
{
|
||||
return bindingType switch
|
||||
{
|
||||
Saml2BindingType.HttpRedirect => Sustainsys.Saml2.WebSso.Saml2BindingType.HttpRedirect,
|
||||
Saml2BindingType.HttpPost => Sustainsys.Saml2.WebSso.Saml2BindingType.HttpPost,
|
||||
_ => Sustainsys.Saml2.WebSso.Saml2BindingType.HttpPost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public class ExtendedOptionsMonitorCache<TOptions> : IExtendedOptionsMonitorCache<TOptions> where TOptions : class
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Lazy<TOptions>> _cache =
|
||||
new ConcurrentDictionary<string, Lazy<TOptions>>(StringComparer.Ordinal);
|
||||
|
||||
public void AddOrUpdate(string name, TOptions options)
|
||||
public class ExtendedOptionsMonitorCache<TOptions> : IExtendedOptionsMonitorCache<TOptions> where TOptions : class
|
||||
{
|
||||
_cache.AddOrUpdate(name ?? Options.DefaultName, new Lazy<TOptions>(() => options),
|
||||
(string s, Lazy<TOptions> lazy) => new Lazy<TOptions>(() => options));
|
||||
}
|
||||
private readonly ConcurrentDictionary<string, Lazy<TOptions>> _cache =
|
||||
new ConcurrentDictionary<string, Lazy<TOptions>>(StringComparer.Ordinal);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
public void AddOrUpdate(string name, TOptions options)
|
||||
{
|
||||
_cache.AddOrUpdate(name ?? Options.DefaultName, new Lazy<TOptions>(() => options),
|
||||
(string s, Lazy<TOptions> lazy) => new Lazy<TOptions>(() => options));
|
||||
}
|
||||
|
||||
public TOptions GetOrAdd(string name, Func<TOptions> createOptions)
|
||||
{
|
||||
return _cache.GetOrAdd(name ?? Options.DefaultName, new Lazy<TOptions>(createOptions)).Value;
|
||||
}
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
public bool TryAdd(string name, TOptions options)
|
||||
{
|
||||
return _cache.TryAdd(name ?? Options.DefaultName, new Lazy<TOptions>(() => options));
|
||||
}
|
||||
public TOptions GetOrAdd(string name, Func<TOptions> createOptions)
|
||||
{
|
||||
return _cache.GetOrAdd(name ?? Options.DefaultName, new Lazy<TOptions>(createOptions)).Value;
|
||||
}
|
||||
|
||||
public bool TryRemove(string name)
|
||||
{
|
||||
return _cache.TryRemove(name ?? Options.DefaultName, out _);
|
||||
public bool TryAdd(string name, TOptions options)
|
||||
{
|
||||
return _cache.TryAdd(name ?? Options.DefaultName, new Lazy<TOptions>(() => options));
|
||||
}
|
||||
|
||||
public bool TryRemove(string name)
|
||||
{
|
||||
return _cache.TryRemove(name ?? Options.DefaultName, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public interface IDynamicAuthenticationScheme
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
AuthenticationSchemeOptions Options { get; set; }
|
||||
SsoType SsoType { get; set; }
|
||||
public interface IDynamicAuthenticationScheme
|
||||
{
|
||||
AuthenticationSchemeOptions Options { get; set; }
|
||||
SsoType SsoType { get; set; }
|
||||
|
||||
Task Validate();
|
||||
Task Validate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public interface IExtendedOptionsMonitorCache<TOptions> : IOptionsMonitorCache<TOptions> where TOptions : class
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
void AddOrUpdate(string name, TOptions options);
|
||||
public interface IExtendedOptionsMonitorCache<TOptions> : IOptionsMonitorCache<TOptions> where TOptions : class
|
||||
{
|
||||
void AddOrUpdate(string name, TOptions options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,63 @@
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class OpenIdConnectOptionsExtensions
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
public static async Task<bool> CouldHandleAsync(this OpenIdConnectOptions options, string scheme, HttpContext context)
|
||||
public static class OpenIdConnectOptionsExtensions
|
||||
{
|
||||
// Determine this is a valid request for our handler
|
||||
if (options.CallbackPath != context.Request.Path &&
|
||||
options.RemoteSignOutPath != context.Request.Path &&
|
||||
options.SignedOutCallbackPath != context.Request.Path)
|
||||
public static async Task<bool> CouldHandleAsync(this OpenIdConnectOptions options, string scheme, HttpContext context)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.Request.Query["scheme"].FirstOrDefault() == scheme)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse out the message
|
||||
OpenIdConnectMessage message = null;
|
||||
if (string.Equals(context.Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
|
||||
// Determine this is a valid request for our handler
|
||||
if (options.CallbackPath != context.Request.Path &&
|
||||
options.RemoteSignOutPath != context.Request.Path &&
|
||||
options.SignedOutCallbackPath != context.Request.Path)
|
||||
{
|
||||
message = new OpenIdConnectMessage(context.Request.Query.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value)));
|
||||
}
|
||||
else if (string.Equals(context.Request.Method, "POST", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrEmpty(context.Request.ContentType) &&
|
||||
context.Request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.Request.Body.CanRead)
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
message = new OpenIdConnectMessage(form.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value)));
|
||||
}
|
||||
|
||||
var state = message?.State;
|
||||
if (string.IsNullOrWhiteSpace(state))
|
||||
{
|
||||
// State is required, it will fail later on for this reason.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle State if we've gotten that back
|
||||
var decodedState = options.StateDataFormat.Unprotect(state);
|
||||
if (decodedState != null && decodedState.Items.ContainsKey("scheme"))
|
||||
if (context.Request.Query["scheme"].FirstOrDefault() == scheme)
|
||||
{
|
||||
return decodedState.Items["scheme"] == scheme;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
// Parse out the message
|
||||
OpenIdConnectMessage message = null;
|
||||
if (string.Equals(context.Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
message = new OpenIdConnectMessage(context.Request.Query.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value)));
|
||||
}
|
||||
else if (string.Equals(context.Request.Method, "POST", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrEmpty(context.Request.ContentType) &&
|
||||
context.Request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.Request.Body.CanRead)
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
message = new OpenIdConnectMessage(form.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value)));
|
||||
}
|
||||
|
||||
var state = message?.State;
|
||||
if (string.IsNullOrWhiteSpace(state))
|
||||
{
|
||||
// State is required, it will fail later on for this reason.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle State if we've gotten that back
|
||||
var decodedState = options.StateDataFormat.Unprotect(state);
|
||||
if (decodedState != null && decodedState.Items.ContainsKey("scheme"))
|
||||
{
|
||||
return decodedState.Items["scheme"] == scheme;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is likely not an appropriate handler
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is likely not an appropriate handler
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,64 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// OpenID Connect Clients use scope values as defined in 3.3 of OAuth 2.0
|
||||
/// [RFC6749]. These values represent the standard scope values supported
|
||||
/// by OAuth 2.0 and therefore OIDC.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See: https://openid.net/specs/openid-connect-basic-1_0.html#Scopes
|
||||
/// </remarks>
|
||||
public static class OpenIdConnectScopes
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
/// <summary>
|
||||
/// REQUIRED. Informs the Authorization Server that the Client is making
|
||||
/// an OpenID Connect request. If the openid scope value is not present,
|
||||
/// the behavior is entirely unspecified.
|
||||
/// </summary>
|
||||
public const string OpenId = "openid";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the End-User's default
|
||||
/// profile Claims, which are: name, family_name, given_name,
|
||||
/// middle_name, nickname, preferred_username, profile, picture,
|
||||
/// website, gender, birthdate, zoneinfo, locale, and updated_at.
|
||||
/// </summary>
|
||||
public const string Profile = "profile";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the email and
|
||||
/// email_verified Claims.
|
||||
/// </summary>
|
||||
public const string Email = "email";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the address Claim.
|
||||
/// </summary>
|
||||
public const string Address = "address";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the phone_number and
|
||||
/// phone_number_verified Claims.
|
||||
/// </summary>
|
||||
public const string Phone = "phone";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests that an OAuth 2.0 Refresh Token
|
||||
/// be issued that can be used to obtain an Access Token that grants
|
||||
/// access to the End-User's UserInfo Endpoint even when the End-User is
|
||||
/// not present (not logged in).
|
||||
/// </summary>
|
||||
public const string OfflineAccess = "offline_access";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. Authentication Context Class Reference. String specifying
|
||||
/// an Authentication Context Class Reference value that identifies the
|
||||
/// Authentication Context Class that the authentication performed
|
||||
/// satisfied.
|
||||
/// OpenID Connect Clients use scope values as defined in 3.3 of OAuth 2.0
|
||||
/// [RFC6749]. These values represent the standard scope values supported
|
||||
/// by OAuth 2.0 and therefore OIDC.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See: https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.2
|
||||
/// See: https://openid.net/specs/openid-connect-basic-1_0.html#Scopes
|
||||
/// </remarks>
|
||||
public const string Acr = "acr";
|
||||
public static class OpenIdConnectScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// REQUIRED. Informs the Authorization Server that the Client is making
|
||||
/// an OpenID Connect request. If the openid scope value is not present,
|
||||
/// the behavior is entirely unspecified.
|
||||
/// </summary>
|
||||
public const string OpenId = "openid";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the End-User's default
|
||||
/// profile Claims, which are: name, family_name, given_name,
|
||||
/// middle_name, nickname, preferred_username, profile, picture,
|
||||
/// website, gender, birthdate, zoneinfo, locale, and updated_at.
|
||||
/// </summary>
|
||||
public const string Profile = "profile";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the email and
|
||||
/// email_verified Claims.
|
||||
/// </summary>
|
||||
public const string Email = "email";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the address Claim.
|
||||
/// </summary>
|
||||
public const string Address = "address";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests access to the phone_number and
|
||||
/// phone_number_verified Claims.
|
||||
/// </summary>
|
||||
public const string Phone = "phone";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. This scope value requests that an OAuth 2.0 Refresh Token
|
||||
/// be issued that can be used to obtain an Access Token that grants
|
||||
/// access to the End-User's UserInfo Endpoint even when the End-User is
|
||||
/// not present (not logged in).
|
||||
/// </summary>
|
||||
public const string OfflineAccess = "offline_access";
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL. Authentication Context Class Reference. String specifying
|
||||
/// an Authentication Context Class Reference value that identifies the
|
||||
/// Authentication Context Class that the authentication performed
|
||||
/// satisfied.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See: https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.2
|
||||
/// </remarks>
|
||||
public const string Acr = "acr";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,101 +4,102 @@ using System.Xml;
|
||||
using Sustainsys.Saml2;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class Saml2OptionsExtensions
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
public static async Task<bool> CouldHandleAsync(this Saml2Options options, string scheme, HttpContext context)
|
||||
public static class Saml2OptionsExtensions
|
||||
{
|
||||
// Determine this is a valid request for our handler
|
||||
if (!context.Request.Path.StartsWithSegments(options.SPOptions.ModulePath, StringComparison.Ordinal))
|
||||
public static async Task<bool> CouldHandleAsync(this Saml2Options options, string scheme, HttpContext context)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Determine this is a valid request for our handler
|
||||
if (!context.Request.Path.StartsWithSegments(options.SPOptions.ModulePath, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var idp = options.IdentityProviders.IsEmpty ? null : options.IdentityProviders.Default;
|
||||
if (idp == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var idp = options.IdentityProviders.IsEmpty ? null : options.IdentityProviders.Default;
|
||||
if (idp == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.Request.Query["scheme"].FirstOrDefault() == scheme)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// We need to pull out and parse the response or request SAML envelope
|
||||
XmlElement envelope = null;
|
||||
try
|
||||
{
|
||||
if (string.Equals(context.Request.Method, "POST", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.Request.HasFormContentType)
|
||||
{
|
||||
string encodedMessage;
|
||||
if (context.Request.Form.TryGetValue("SAMLResponse", out var response))
|
||||
{
|
||||
encodedMessage = response.FirstOrDefault();
|
||||
}
|
||||
else
|
||||
{
|
||||
encodedMessage = context.Request.Form["SAMLRequest"];
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(encodedMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
envelope = XmlHelpers.XmlDocumentFromString(
|
||||
Encoding.UTF8.GetString(Convert.FromBase64String(encodedMessage)))?.DocumentElement;
|
||||
}
|
||||
else if (string.Equals(context.Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var encodedPayload = context.Request.Query["SAMLRequest"].FirstOrDefault() ??
|
||||
context.Request.Query["SAMLResponse"].FirstOrDefault();
|
||||
try
|
||||
{
|
||||
var payload = Convert.FromBase64String(encodedPayload);
|
||||
using var compressed = new MemoryStream(payload);
|
||||
using var decompressedStream = new DeflateStream(compressed, CompressionMode.Decompress, true);
|
||||
using var deCompressed = new MemoryStream();
|
||||
await decompressedStream.CopyToAsync(deCompressed);
|
||||
|
||||
envelope = XmlHelpers.XmlDocumentFromString(
|
||||
Encoding.UTF8.GetString(deCompressed.GetBuffer(), 0, (int)deCompressed.Length))?.DocumentElement;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new FormatException($"\'{encodedPayload}\' is not a valid Base64 encoded string: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (envelope == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Double check the entity Ids
|
||||
var entityId = envelope["Issuer", Saml2Namespaces.Saml2Name]?.InnerText.Trim();
|
||||
if (!string.Equals(entityId, idp.EntityId.Id, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.SPOptions.WantAssertionsSigned)
|
||||
{
|
||||
var assertion = envelope["Assertion", Saml2Namespaces.Saml2Name];
|
||||
var isAssertionSigned = assertion != null && XmlHelpers.IsSignedByAny(assertion, idp.SigningKeys,
|
||||
options.SPOptions.ValidateCertificates, options.SPOptions.MinIncomingSigningAlgorithm);
|
||||
if (!isAssertionSigned)
|
||||
{
|
||||
throw new Exception("Cannot verify SAML assertion signature.");
|
||||
}
|
||||
}
|
||||
|
||||
if (context.Request.Query["scheme"].FirstOrDefault() == scheme)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// We need to pull out and parse the response or request SAML envelope
|
||||
XmlElement envelope = null;
|
||||
try
|
||||
{
|
||||
if (string.Equals(context.Request.Method, "POST", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.Request.HasFormContentType)
|
||||
{
|
||||
string encodedMessage;
|
||||
if (context.Request.Form.TryGetValue("SAMLResponse", out var response))
|
||||
{
|
||||
encodedMessage = response.FirstOrDefault();
|
||||
}
|
||||
else
|
||||
{
|
||||
encodedMessage = context.Request.Form["SAMLRequest"];
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(encodedMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
envelope = XmlHelpers.XmlDocumentFromString(
|
||||
Encoding.UTF8.GetString(Convert.FromBase64String(encodedMessage)))?.DocumentElement;
|
||||
}
|
||||
else if (string.Equals(context.Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var encodedPayload = context.Request.Query["SAMLRequest"].FirstOrDefault() ??
|
||||
context.Request.Query["SAMLResponse"].FirstOrDefault();
|
||||
try
|
||||
{
|
||||
var payload = Convert.FromBase64String(encodedPayload);
|
||||
using var compressed = new MemoryStream(payload);
|
||||
using var decompressedStream = new DeflateStream(compressed, CompressionMode.Decompress, true);
|
||||
using var deCompressed = new MemoryStream();
|
||||
await decompressedStream.CopyToAsync(deCompressed);
|
||||
|
||||
envelope = XmlHelpers.XmlDocumentFromString(
|
||||
Encoding.UTF8.GetString(deCompressed.GetBuffer(), 0, (int)deCompressed.Length))?.DocumentElement;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new FormatException($"\'{encodedPayload}\' is not a valid Base64 encoded string: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (envelope == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Double check the entity Ids
|
||||
var entityId = envelope["Issuer", Saml2Namespaces.Saml2Name]?.InnerText.Trim();
|
||||
if (!string.Equals(entityId, idp.EntityId.Id, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.SPOptions.WantAssertionsSigned)
|
||||
{
|
||||
var assertion = envelope["Assertion", Saml2Namespaces.Saml2Name];
|
||||
var isAssertionSigned = assertion != null && XmlHelpers.IsSignedByAny(assertion, idp.SigningKeys,
|
||||
options.SPOptions.ValidateCertificates, options.SPOptions.MinIncomingSigningAlgorithm);
|
||||
if (!isAssertionSigned)
|
||||
{
|
||||
throw new Exception("Cannot verify SAML assertion signature.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class SamlClaimTypes
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
public const string Email = "urn:oid:0.9.2342.19200300.100.1.3";
|
||||
public const string GivenName = "urn:oid:2.5.4.42";
|
||||
public const string Surname = "urn:oid:2.5.4.4";
|
||||
public const string DisplayName = "urn:oid:2.16.840.1.113730.3.1.241";
|
||||
public const string CommonName = "urn:oid:2.5.4.3";
|
||||
public const string UserId = "urn:oid:0.9.2342.19200300.100.1.1";
|
||||
public static class SamlClaimTypes
|
||||
{
|
||||
public const string Email = "urn:oid:0.9.2342.19200300.100.1.3";
|
||||
public const string GivenName = "urn:oid:2.5.4.42";
|
||||
public const string Surname = "urn:oid:2.5.4.4";
|
||||
public const string DisplayName = "urn:oid:2.16.840.1.113730.3.1.241";
|
||||
public const string CommonName = "urn:oid:2.5.4.3";
|
||||
public const string UserId = "urn:oid:0.9.2342.19200300.100.1.1";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class SamlNameIdFormats
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
// Common
|
||||
public const string Unspecified = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified";
|
||||
public const string Email = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
||||
public const string Persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent";
|
||||
public const string Transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";
|
||||
// Not-so-common
|
||||
public const string Upn = "http://schemas.xmlsoap.org/claims/UPN";
|
||||
public const string CommonName = "http://schemas.xmlsoap.org/claims/CommonName";
|
||||
public const string X509SubjectName = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName";
|
||||
public const string WindowsQualifiedDomainName = "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName";
|
||||
public const string KerberosPrincipalName = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos";
|
||||
public const string EntityIdentifier = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity";
|
||||
public static class SamlNameIdFormats
|
||||
{
|
||||
// Common
|
||||
public const string Unspecified = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified";
|
||||
public const string Email = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
||||
public const string Persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent";
|
||||
public const string Transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";
|
||||
// Not-so-common
|
||||
public const string Upn = "http://schemas.xmlsoap.org/claims/UPN";
|
||||
public const string CommonName = "http://schemas.xmlsoap.org/claims/CommonName";
|
||||
public const string X509SubjectName = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName";
|
||||
public const string WindowsQualifiedDomainName = "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName";
|
||||
public const string KerberosPrincipalName = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos";
|
||||
public const string EntityIdentifier = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class SamlPropertyKeys
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
public const string ClaimFormat = "http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format";
|
||||
public static class SamlPropertyKeys
|
||||
{
|
||||
public const string ClaimFormat = "http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,69 +9,70 @@ using IdentityServer4.ResponseHandling;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
public static IServiceCollection AddSsoServices(this IServiceCollection services,
|
||||
GlobalSettings globalSettings)
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
// SAML SP Configuration
|
||||
var samlEnvironment = new SamlEnvironment
|
||||
public static IServiceCollection AddSsoServices(this IServiceCollection services,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
SpSigningCertificate = CoreHelpers.GetIdentityServerCertificate(globalSettings),
|
||||
};
|
||||
services.AddSingleton(s => samlEnvironment);
|
||||
|
||||
services.AddSingleton<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider,
|
||||
DynamicAuthenticationSchemeProvider>();
|
||||
// Oidc
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IPostConfigureOptions<OpenIdConnectOptions>,
|
||||
OpenIdConnectPostConfigureOptions>();
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IOptionsMonitorCache<OpenIdConnectOptions>,
|
||||
ExtendedOptionsMonitorCache<OpenIdConnectOptions>>();
|
||||
// Saml2
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IPostConfigureOptions<Saml2Options>,
|
||||
PostConfigureSaml2Options>();
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IOptionsMonitorCache<Saml2Options>,
|
||||
ExtendedOptionsMonitorCache<Saml2Options>>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IIdentityServerBuilder AddSsoIdentityServerServices(this IServiceCollection services,
|
||||
IWebHostEnvironment env, GlobalSettings globalSettings)
|
||||
{
|
||||
services.AddTransient<IDiscoveryResponseGenerator, DiscoveryResponseGenerator>();
|
||||
|
||||
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalSso);
|
||||
var identityServerBuilder = services
|
||||
.AddIdentityServer(options =>
|
||||
// SAML SP Configuration
|
||||
var samlEnvironment = new SamlEnvironment
|
||||
{
|
||||
options.IssuerUri = $"{issuerUri.Scheme}://{issuerUri.Host}";
|
||||
if (env.IsDevelopment())
|
||||
SpSigningCertificate = CoreHelpers.GetIdentityServerCertificate(globalSettings),
|
||||
};
|
||||
services.AddSingleton(s => samlEnvironment);
|
||||
|
||||
services.AddSingleton<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider,
|
||||
DynamicAuthenticationSchemeProvider>();
|
||||
// Oidc
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IPostConfigureOptions<OpenIdConnectOptions>,
|
||||
OpenIdConnectPostConfigureOptions>();
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IOptionsMonitorCache<OpenIdConnectOptions>,
|
||||
ExtendedOptionsMonitorCache<OpenIdConnectOptions>>();
|
||||
// Saml2
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IPostConfigureOptions<Saml2Options>,
|
||||
PostConfigureSaml2Options>();
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IOptionsMonitorCache<Saml2Options>,
|
||||
ExtendedOptionsMonitorCache<Saml2Options>>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IIdentityServerBuilder AddSsoIdentityServerServices(this IServiceCollection services,
|
||||
IWebHostEnvironment env, GlobalSettings globalSettings)
|
||||
{
|
||||
services.AddTransient<IDiscoveryResponseGenerator, DiscoveryResponseGenerator>();
|
||||
|
||||
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalSso);
|
||||
var identityServerBuilder = services
|
||||
.AddIdentityServer(options =>
|
||||
{
|
||||
options.Authentication.CookieSameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode.Unspecified;
|
||||
}
|
||||
else
|
||||
options.IssuerUri = $"{issuerUri.Scheme}://{issuerUri.Host}";
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
options.Authentication.CookieSameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode.Unspecified;
|
||||
}
|
||||
else
|
||||
{
|
||||
options.UserInteraction.ErrorUrl = "/Error";
|
||||
options.UserInteraction.ErrorIdParameter = "errorId";
|
||||
}
|
||||
options.InputLengthRestrictions.UserName = 256;
|
||||
})
|
||||
.AddInMemoryCaching()
|
||||
.AddInMemoryClients(new List<Client>
|
||||
{
|
||||
options.UserInteraction.ErrorUrl = "/Error";
|
||||
options.UserInteraction.ErrorIdParameter = "errorId";
|
||||
}
|
||||
options.InputLengthRestrictions.UserName = 256;
|
||||
})
|
||||
.AddInMemoryCaching()
|
||||
.AddInMemoryClients(new List<Client>
|
||||
{
|
||||
new OidcIdentityClient(globalSettings)
|
||||
})
|
||||
.AddInMemoryIdentityResources(new List<IdentityResource>
|
||||
{
|
||||
new IdentityResources.OpenId(),
|
||||
new IdentityResources.Profile()
|
||||
})
|
||||
.AddIdentityServerCertificate(env, globalSettings);
|
||||
new OidcIdentityClient(globalSettings)
|
||||
})
|
||||
.AddInMemoryIdentityResources(new List<IdentityResource>
|
||||
{
|
||||
new IdentityResources.OpenId(),
|
||||
new IdentityResources.Profile()
|
||||
})
|
||||
.AddIdentityServerCertificate(env, globalSettings);
|
||||
|
||||
return identityServerBuilder;
|
||||
return identityServerBuilder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,82 +3,83 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public class SsoAuthenticationMiddleware
|
||||
namespace Bit.Sso.Utilities
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public SsoAuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes)
|
||||
public class SsoAuthenticationMiddleware
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
Schemes = schemes ?? throw new ArgumentNullException(nameof(schemes));
|
||||
}
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public IAuthenticationSchemeProvider Schemes { get; set; }
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
if ((context.Request.Method == "GET" && context.Request.Query.ContainsKey("SAMLart"))
|
||||
|| (context.Request.Method == "POST" && context.Request.Form.ContainsKey("SAMLart")))
|
||||
public SsoAuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes)
|
||||
{
|
||||
throw new Exception("SAMLart parameter detected. SAML Artifact binding is not allowed.");
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
Schemes = schemes ?? throw new ArgumentNullException(nameof(schemes));
|
||||
}
|
||||
|
||||
context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
|
||||
{
|
||||
OriginalPath = context.Request.Path,
|
||||
OriginalPathBase = context.Request.PathBase
|
||||
});
|
||||
public IAuthenticationSchemeProvider Schemes { get; set; }
|
||||
|
||||
// Give any IAuthenticationRequestHandler schemes a chance to handle the request
|
||||
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
|
||||
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
// Determine if scheme is appropriate for the current context FIRST
|
||||
if (scheme is IDynamicAuthenticationScheme dynamicScheme)
|
||||
if ((context.Request.Method == "GET" && context.Request.Query.ContainsKey("SAMLart"))
|
||||
|| (context.Request.Method == "POST" && context.Request.Form.ContainsKey("SAMLart")))
|
||||
{
|
||||
switch (dynamicScheme.SsoType)
|
||||
throw new Exception("SAMLart parameter detected. SAML Artifact binding is not allowed.");
|
||||
}
|
||||
|
||||
context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
|
||||
{
|
||||
OriginalPath = context.Request.Path,
|
||||
OriginalPathBase = context.Request.PathBase
|
||||
});
|
||||
|
||||
// Give any IAuthenticationRequestHandler schemes a chance to handle the request
|
||||
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
|
||||
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
|
||||
{
|
||||
// Determine if scheme is appropriate for the current context FIRST
|
||||
if (scheme is IDynamicAuthenticationScheme dynamicScheme)
|
||||
{
|
||||
case SsoType.OpenIdConnect:
|
||||
default:
|
||||
if (dynamicScheme.Options is OpenIdConnectOptions oidcOptions &&
|
||||
!await oidcOptions.CouldHandleAsync(scheme.Name, context))
|
||||
{
|
||||
// It's OIDC and Dynamic, but not a good fit
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
case SsoType.Saml2:
|
||||
if (dynamicScheme.Options is Saml2Options samlOptions &&
|
||||
!await samlOptions.CouldHandleAsync(scheme.Name, context))
|
||||
{
|
||||
// It's SAML and Dynamic, but not a good fit
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
switch (dynamicScheme.SsoType)
|
||||
{
|
||||
case SsoType.OpenIdConnect:
|
||||
default:
|
||||
if (dynamicScheme.Options is OpenIdConnectOptions oidcOptions &&
|
||||
!await oidcOptions.CouldHandleAsync(scheme.Name, context))
|
||||
{
|
||||
// It's OIDC and Dynamic, but not a good fit
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
case SsoType.Saml2:
|
||||
if (dynamicScheme.Options is Saml2Options samlOptions &&
|
||||
!await samlOptions.CouldHandleAsync(scheme.Name, context))
|
||||
{
|
||||
// It's SAML and Dynamic, but not a good fit
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// This far it's not dynamic OR it is but "could" be handled
|
||||
if (await handlers.GetHandlerAsync(context, scheme.Name) is IAuthenticationRequestHandler handler &&
|
||||
await handler.HandleRequestAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// This far it's not dynamic OR it is but "could" be handled
|
||||
if (await handlers.GetHandlerAsync(context, scheme.Name) is IAuthenticationRequestHandler handler &&
|
||||
await handler.HandleRequestAsync())
|
||||
// Fallback to the default scheme from the provider
|
||||
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
|
||||
if (defaultAuthenticate != null)
|
||||
{
|
||||
return;
|
||||
var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
|
||||
if (result?.Principal != null)
|
||||
{
|
||||
context.User = result.Principal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the default scheme from the provider
|
||||
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
|
||||
if (defaultAuthenticate != null)
|
||||
{
|
||||
var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
|
||||
if (result?.Principal != null)
|
||||
{
|
||||
context.User = result.Principal;
|
||||
}
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user