1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-22 16:57:36 +01:00

Create sso user api (#886)

* facilitate linking/unlinking existing users from an sso enabled org

* added user_identifier to identity methods for sso

* moved sso user delete method to account controller

* fixed a broken test

* Update AccountsController.cs

* facilitate linking/unlinking existing users from an sso enabled org

* added user_identifier to identity methods for sso

* moved sso user delete method to account controller

* fixed a broken test

* added a token to the existing user sso link flow

* added a token to the existing user sso link flow

* fixed a typo

* added an event log for unlink ssoUser records

* fixed a merge issue

* fixed a busted test

* fixed a busted test

* ran a formatter over everything & changed .vscode settings in .gitignore

* chagned a variable to use string interpolation

* removed a blank line

* Changed TokenPurpose enum to a static class of strings

* code review cleanups

* formatting fix

* Changed parameters & logging for delete sso user

* changed th method used to get organization user for deleting sso user records

Co-authored-by: Kyle Spearrin <kspearrin@users.noreply.github.com>
This commit is contained in:
Addison Beck 2020-08-26 14:12:04 -04:00 committed by GitHub
parent 7cc9ce7bd5
commit 59f8467f7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 214 additions and 64 deletions

4
.gitignore vendored
View File

@ -206,4 +206,6 @@ src/Core/Properties/launchSettings.json
*.override.env
**/*.DS_Store
src/Admin/wwwroot/lib
src/Admin/wwwroot/css
src/Admin/wwwroot/css
.vscode/*
**/.vscode/*

View File

@ -1,21 +1,22 @@
using System;
using System.Threading.Tasks;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Bit.Core.Models.Api.Request.Accounts;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Table;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Bit.Core.Models.Api;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Enums;
using System.Linq;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Core;
using Bit.Core.Models.Business;
using Bit.Api.Utilities;
using Bit.Core.Models.Table;
using System;
using System.Collections.Generic;
using Bit.Core.Models.Api.Request.Accounts;
using Bit.Core.Models.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Bit.Api.Controllers
{
@ -23,30 +24,34 @@ namespace Bit.Api.Controllers
[Authorize("Application")]
public class AccountsController : Controller
{
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly GlobalSettings _globalSettings;
private readonly ICipherRepository _cipherRepository;
private readonly IFolderRepository _folderRepository;
private readonly IOrganizationService _organizationService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPaymentService _paymentService;
private readonly GlobalSettings _globalSettings;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
public AccountsController(
IUserService userService,
IUserRepository userRepository,
GlobalSettings globalSettings,
ICipherRepository cipherRepository,
IFolderRepository folderRepository,
IOrganizationService organizationService,
IOrganizationUserRepository organizationUserRepository,
IPaymentService paymentService,
GlobalSettings globalSettings)
ISsoUserRepository ssoUserRepository,
IUserRepository userRepository,
IUserService userService)
{
_userService = userService;
_userRepository = userRepository;
_cipherRepository = cipherRepository;
_folderRepository = folderRepository;
_globalSettings = globalSettings;
_organizationService = organizationService;
_organizationUserRepository = organizationUserRepository;
_paymentService = paymentService;
_globalSettings = globalSettings;
_userRepository = userRepository;
_userService = userService;
}
[HttpPost("prelogin")]
@ -195,7 +200,7 @@ namespace Bit.Api.Controllers
await Task.Delay(2000);
throw new BadRequestException(ModelState);
}
[HttpPost("set-password")]
public async Task PostSetPasswordAsync([FromBody]SetPasswordRequestModel model)
{
@ -708,5 +713,27 @@ namespace Bit.Api.Controllers
};
await _paymentService.SaveTaxInfoAsync(user, taxInfo);
}
[HttpDelete("sso/{organizationId}")]
public async Task DeleteSsoUser(string organizationId)
{
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
await _organizationService.DeleteSsoUserAsync(userId.Value, new Guid(organizationId));
}
[HttpGet("sso/user-identifier")]
public async Task<string> GetSsoUserIdentifier()
{
var user = await _userService.GetUserByPrincipalAsync(User);
var token = await _userService.GenerateSignInTokenAsync(user, TokenPurposes.LinkSso);
var bytes = Encoding.UTF8.GetBytes($"{user.Id},{token}");
var userIdentifier = Convert.ToBase64String(bytes);
return userIdentifier;
}
}
}

View File

@ -4,4 +4,9 @@
{
public const int BypassFiltersEventId = 12482444;
}
public static class TokenPurposes
{
public const string LinkSso = "LinkSso";
}
}

View File

@ -42,6 +42,7 @@
OrganizationUser_Updated = 1502,
OrganizationUser_Removed = 1503,
OrganizationUser_UpdatedGroups = 1504,
OrganizationUser_UnlinkedSso = 1505,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@ -28,6 +28,7 @@ namespace Bit.Core.Models.Api
Type = organization.Type;
Enabled = organization.Enabled;
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
Identifier = organization.Identifier;
}
public string Id { get; set; }
@ -51,5 +52,6 @@ namespace Bit.Core.Models.Api
public OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public bool SsoBound { get; set; }
public string Identifier { get; set; }
}
}

View File

@ -25,5 +25,6 @@ namespace Bit.Core.Models.Data
public Enums.OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public string SsoExternalId { get; set; }
public string Identifier { get; set; }
}
}

View File

@ -1,8 +1,11 @@
using Bit.Core.Models.Table;
using Bit.Core.Models.Table;
using System;
using System.Threading.Tasks;
namespace Bit.Core.Repositories
{
public interface ISsoUserRepository : IRepository<SsoUser, long>
{
Task DeleteAsync(Guid userId, Guid? organizationId);
}
}

View File

@ -1,4 +1,9 @@
using Bit.Core.Models.Table;
using Dapper;
using System;
using System.Threading.Tasks;
using System.Data.SqlClient;
using System.Data;
namespace Bit.Core.Repositories.SqlServer
{
@ -11,5 +16,16 @@ namespace Bit.Core.Repositories.SqlServer
public SsoUserRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task DeleteAsync(Guid userId, Guid? organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[SsoUser_Delete]",
new { UserId = userId, OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
}
}
}
}

View File

@ -51,5 +51,6 @@ namespace Bit.Core.Services
IEnumerable<ImportedOrganizationUser> newUsers, IEnumerable<string> removeUserExternalIds,
bool overwriteExisting);
Task RotateApiKeyAsync(Organization organization);
Task DeleteSsoUserAsync(Guid userId, Guid? organizationId);
}
}

View File

@ -68,5 +68,6 @@ namespace Bit.Core.Services
Task<bool> TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user);
Task<bool> TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user);
Task<string> GenerateEnterprisePortalSignInTokenAsync(User user);
Task<string> GenerateSignInTokenAsync(User user, string purpose);
}
}

View File

@ -35,6 +35,7 @@ namespace Bit.Core.Services
private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IReferenceEventService _referenceEventService;
private readonly GlobalSettings _globalSettings;
@ -56,6 +57,7 @@ namespace Bit.Core.Services
IPaymentService paymentService,
IPolicyRepository policyRepository,
ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository,
IReferenceEventService referenceEventService,
GlobalSettings globalSettings)
{
@ -76,6 +78,7 @@ namespace Bit.Core.Services
_paymentService = paymentService;
_policyRepository = policyRepository;
_ssoConfigRepository = ssoConfigRepository;
_ssoUserRepository = ssoUserRepository;
_referenceEventService = referenceEventService;
_globalSettings = globalSettings;
}
@ -1497,6 +1500,19 @@ namespace Bit.Core.Services
await ReplaceAndUpdateCache(organization);
}
public async Task DeleteSsoUserAsync(Guid userId, Guid? organizationId)
{
await _ssoUserRepository.DeleteAsync(userId, organizationId);
if (organizationId.HasValue)
{
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId);
if (organizationUser != null)
{
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UnlinkedSso);
}
}
}
private async Task UpdateUsersAsync(Group group, HashSet<string> groupUsers,
Dictionary<string, Guid> existingUsersIdDict, HashSet<Guid> existingUsers = null)
{

View File

@ -1087,6 +1087,7 @@ namespace Bit.Core.Services
return await CanAccessPremium(user);
}
//TODO refactor this to use the below method and enum
public async Task<string> GenerateEnterprisePortalSignInTokenAsync(User user)
{
var token = await GenerateUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider,
@ -1094,6 +1095,14 @@ namespace Bit.Core.Services
return token;
}
public async Task<string> GenerateSignInTokenAsync(User user, string purpose)
{
var token = await GenerateUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider,
purpose);
return token;
}
private async Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
bool validatePassword = true, bool refreshStamp = true)
{

View File

@ -1,62 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Bit.Core.Models.Table;
using Bit.Core.Models.Table;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Identity.Models;
using IdentityModel;
using IdentityServer4;
using IdentityServer4.Services;
using IdentityServer4.Stores;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Bit.Identity.Controllers
{
public class AccountController : Controller
{
private readonly IIdentityServerInteractionService _interaction;
private readonly IUserRepository _userRepository;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IClientStore _clientStore;
private readonly IIdentityServerInteractionService _interaction;
private readonly ILogger<AccountController> _logger;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IUserRepository _userRepository;
public AccountController(
IIdentityServerInteractionService interaction,
IUserRepository userRepository,
ISsoConfigRepository ssoConfigRepository,
IClientStore clientStore,
ILogger<AccountController> logger)
IIdentityServerInteractionService interaction,
ILogger<AccountController> logger,
IOrganizationUserRepository organizationUserRepository,
ISsoConfigRepository ssoConfigRepository,
IUserRepository userRepository,
IUserService userService)
{
_interaction = interaction;
_userRepository = userRepository;
_ssoConfigRepository = ssoConfigRepository;
_clientStore = clientStore;
_interaction = interaction;
_logger = logger;
_ssoConfigRepository = ssoConfigRepository;
_userRepository = userRepository;
}
[HttpGet]
public async Task<IActionResult> Login(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context.Parameters.AllKeys.Contains("domain_hint") &&
!string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
var domainHint = context.Parameters.AllKeys.Contains("domain_hint") ?
context.Parameters["domain_hint"] : null;
if (string.IsNullOrWhiteSpace(domainHint))
{
return RedirectToAction(nameof(ExternalChallenge),
new { organizationIdentifier = context.Parameters["domain_hint"], returnUrl = returnUrl });
throw new Exception("No domain_hint provided");
}
else
var userIdentifier = context.Parameters.AllKeys.Contains("user_identifier") ?
context.Parameters["user_identifier"] : null;
return RedirectToAction(nameof(ExternalChallenge), new
{
throw new Exception("No domain_hint provided.");
}
organizationIdentifier = domainHint,
returnUrl,
userIdentifier
});
}
[HttpGet]
public async Task<IActionResult> ExternalChallenge(string organizationIdentifier, string returnUrl)
public async Task<IActionResult> ExternalChallenge(string organizationIdentifier, string returnUrl,
string userIdentifier)
{
if (string.IsNullOrWhiteSpace(organizationIdentifier))
{
@ -82,6 +95,11 @@ namespace Bit.Identity.Controllers
},
};
if (!string.IsNullOrWhiteSpace(userIdentifier))
{
props.Items.Add("user_identifier", userIdentifier);
}
return Challenge(props, scheme);
}

View File

@ -100,6 +100,7 @@ namespace Bit.Identity
{
// Pass domain_hint onto the sso idp
context.ProtocolMessage.DomainHint = context.Properties.Items["domain_hint"];
context.ProtocolMessage.SessionState = context.Properties.Items["user_identifier"];
return Task.FromResult(0);
}
};

View File

@ -18,6 +18,7 @@ SELECT
O.[Seats],
O.[MaxCollections],
O.[MaxStorageGb],
O.[Identifier],
OU.[Key],
OU.[Status],
OU.[Type],
@ -27,4 +28,4 @@ FROM
INNER JOIN
[dbo].[Organization] O ON O.[Id] = OU.[OrganizationId]
LEFT JOIN
[dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId]
[dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId]

View File

@ -1,6 +1,3 @@
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Bit.Api.Controllers;
using Bit.Core;
using Bit.Core.Enums;
@ -12,21 +9,26 @@ using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Xunit;
namespace Bit.Api.Test.Controllers
{
public class AccountsControllerTests : IDisposable
{
private readonly AccountsController _sut;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly AccountsController _sut;
private readonly GlobalSettings _globalSettings;
private readonly ICipherRepository _cipherRepository;
private readonly IFolderRepository _folderRepository;
private readonly IOrganizationService _organizationService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPaymentService _paymentService;
private readonly GlobalSettings _globalSettings;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
public AccountsControllerTests()
{
@ -34,17 +36,20 @@ namespace Bit.Api.Test.Controllers
_userRepository = Substitute.For<IUserRepository>();
_cipherRepository = Substitute.For<ICipherRepository>();
_folderRepository = Substitute.For<IFolderRepository>();
_organizationService = Substitute.For<IOrganizationService>();
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_paymentService = Substitute.For<IPaymentService>();
_globalSettings = new GlobalSettings();
_sut = new AccountsController(
_userService,
_userRepository,
_globalSettings,
_cipherRepository,
_folderRepository,
_organizationService,
_organizationUserRepository,
_paymentService,
_globalSettings
_ssoUserRepository,
_userRepository,
_userService
);
}

View File

@ -33,13 +33,14 @@ namespace Bit.Core.Test.Services
var paymentService = Substitute.For<IPaymentService>();
var policyRepo = Substitute.For<IPolicyRepository>();
var ssoConfigRepo = Substitute.For<ISsoConfigRepository>();
var ssoUserRepo = Substitute.For<ISsoUserRepository>();
var referenceEventService = Substitute.For<IReferenceEventService>();
var globalSettings = Substitute.For<GlobalSettings>();
var orgService = new OrganizationService(orgRepo, orgUserRepo, collectionRepo, userRepo,
groupRepo, dataProtector, mailService, pushNotService, pushRegService, deviceRepo,
licenseService, eventService, installationRepo, appCacheService, paymentService, policyRepo,
ssoConfigRepo, referenceEventService, globalSettings);
ssoConfigRepo, ssoUserRepo, referenceEventService, globalSettings);
var id = Guid.NewGuid();
var userId = Guid.NewGuid();
@ -93,13 +94,14 @@ namespace Bit.Core.Test.Services
var paymentService = Substitute.For<IPaymentService>();
var policyRepo = Substitute.For<IPolicyRepository>();
var ssoConfigRepo = Substitute.For<ISsoConfigRepository>();
var ssoUserRepo = Substitute.For<ISsoUserRepository>();
var referenceEventService = Substitute.For<IReferenceEventService>();
var globalSettings = Substitute.For<GlobalSettings>();
var orgService = new OrganizationService(orgRepo, orgUserRepo, collectionRepo, userRepo,
groupRepo, dataProtector, mailService, pushNotService, pushRegService, deviceRepo,
licenseService, eventService, installationRepo, appCacheService, paymentService, policyRepo,
ssoConfigRepo, referenceEventService, globalSettings);
ssoConfigRepo, ssoUserRepo, referenceEventService, globalSettings);
var id = Guid.NewGuid();
var userId = Guid.NewGuid();

View File

@ -0,0 +1,39 @@
IF EXISTS(SELECT * FROM sys.views WHERE [Name] = 'OrganizationUserOrganizationDetailsView')
BEGIN
DROP VIEW [dbo].[OrganizationUserOrganizationDetailsView];
END
GO
CREATE VIEW [dbo].[OrganizationUserOrganizationDetailsView]
AS
SELECT
OU.[UserId],
OU.[OrganizationId],
O.[Name],
O.[Enabled],
O.[UsePolicies],
O.[UseSso],
O.[UseGroups],
O.[UseDirectory],
O.[UseEvents],
O.[UseTotp],
O.[Use2fa],
O.[UseApi],
O.[SelfHost],
O.[UsersGetPremium],
O.[Seats],
O.[MaxCollections],
O.[MaxStorageGb],
O.[Identifier],
OU.[Key],
OU.[Status],
OU.[Type],
SU.[ExternalId] SsoExternalId
FROM
[dbo].[OrganizationUser] OU
INNER JOIN
[dbo].[Organization] O ON O.[Id] = OU.[OrganizationId]
LEFT JOIN
[dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId]
GO