From b7254519f046874bbe400087a5527bb8ad807a47 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 23 Mar 2017 00:17:34 -0400 Subject: [PATCH] organization user apis, hardening, completeness --- src/Api/Controllers/AccountsController.cs | 2 +- .../OrganizationUsersController.cs | 30 ++-- src/Core/Services/IMailService.cs | 1 + src/Core/Services/IOrganizationService.cs | 10 +- src/Core/Services/IUserService.cs | 1 + .../Implementations/NoopMailService.cs | 8 +- .../Implementations/OrganizationService.cs | 131 ++++++++++++++++-- .../Implementations/SendGridMailService.cs | 14 ++ src/Core/Utilities/CoreHelpers.cs | 7 +- 9 files changed, 172 insertions(+), 32 deletions(-) diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index d3155c1638..133f61611f 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -192,7 +192,7 @@ namespace Bit.Api.Controllers if(userId.HasValue) { var date = await _userService.GetAccountRevisionDateByIdAsync(userId.Value); - revisionDate = Core.Utilities.CoreHelpers.EpocMilliseconds(date); + revisionDate = Core.Utilities.CoreHelpers.ToEpocMilliseconds(date); } return revisionDate; diff --git a/src/Api/Controllers/OrganizationUsersController.cs b/src/Api/Controllers/OrganizationUsersController.cs index 7f3eea6b8b..8f10f9d2d9 100644 --- a/src/Api/Controllers/OrganizationUsersController.cs +++ b/src/Api/Controllers/OrganizationUsersController.cs @@ -57,11 +57,19 @@ namespace Bit.Api.Controllers [HttpPost("invite")] public async Task Invite(string orgId, [FromBody]OrganizationUserInviteRequestModel model) { - var user = await _userService.GetUserByPrincipalAsync(User); - var result = await _organizationService.InviteUserAsync(new Guid(orgId), model.Email, model.Type, + var userId = _userService.GetProperUserId(User); + var result = await _organizationService.InviteUserAsync(new Guid(orgId), userId.Value, model.Email, model.Type, model.Subvaults?.Select(s => s.ToSubvaultUser())); } + [HttpPut("{id}/reinvite")] + [HttpPost("{id}/reinvite")] + public async Task Reinvite(string orgId, string id) + { + var userId = _userService.GetProperUserId(User); + await _organizationService.ResendInviteAsync(new Guid(orgId), userId.Value, new Guid(id)); + } + [HttpPut("{id}/accept")] [HttpPost("{id}/accept")] public async Task Accept(string orgId, string id, [FromBody]OrganizationUserAcceptRequestModel model) @@ -74,12 +82,13 @@ namespace Bit.Api.Controllers [HttpPost("{id}/confirm")] public async Task Confirm(string orgId, string id, [FromBody]OrganizationUserConfirmRequestModel model) { - var result = await _organizationService.ConfirmUserAsync(new Guid(id), model.Key); + var userId = _userService.GetProperUserId(User); + var result = await _organizationService.ConfirmUserAsync(new Guid(orgId), new Guid(id), model.Key, userId.Value); } [HttpPut("{id}")] [HttpPost("{id}")] - public async Task Put(string id, [FromBody]OrganizationUserUpdateRequestModel model) + public async Task Put(string orgId, string id, [FromBody]OrganizationUserUpdateRequestModel model) { var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id)); if(organizationUser == null) @@ -87,7 +96,8 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - await _organizationService.SaveUserAsync(model.ToOrganizationUser(organizationUser), + var userId = _userService.GetProperUserId(User); + await _organizationService.SaveUserAsync(model.ToOrganizationUser(organizationUser), userId.Value, model.Subvaults?.Select(s => s.ToSubvaultUser())); } @@ -95,14 +105,8 @@ namespace Bit.Api.Controllers [HttpPost("{id}/delete")] public async Task Delete(string orgId, string id) { - var organization = await _organizationRepository.GetByIdAsync(new Guid(id), - _userService.GetProperUserId(User).Value); - if(organization == null) - { - throw new NotFoundException(); - } - - await _organizationRepository.DeleteAsync(organization); + var userId = _userService.GetProperUserId(User); + await _organizationService.DeleteUserAsync(new Guid(orgId), new Guid(id), userId.Value); } } } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 96d0617d2f..cfe737b8e2 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -10,5 +10,6 @@ namespace Bit.Core.Services Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); + Task SendOrganizationInviteEmailAsync(string organizationName, string email, string token); } } \ No newline at end of file diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 8ac170a621..7c31cc14c1 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -9,10 +9,12 @@ namespace Bit.Core.Services public interface IOrganizationService { Task> SignUpAsync(OrganizationSignup organizationSignup); - Task InviteUserAsync(Guid organizationId, string email, Enums.OrganizationUserType type, - IEnumerable subvaults); + Task InviteUserAsync(Guid organizationId, Guid invitingUserId, string email, + Enums.OrganizationUserType type, IEnumerable subvaults); + Task ResendInviteAsync(Guid organizationId, Guid invitingUserId, Guid organizationUserId); Task AcceptUserAsync(Guid organizationUserId, User user, string token); - Task ConfirmUserAsync(Guid organizationUserId, string key); - Task SaveUserAsync(OrganizationUser user, IEnumerable subvaults); + Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); + Task SaveUserAsync(OrganizationUser user, Guid savingUserId, IEnumerable subvaults); + Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId); } } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ca6c6f3f1c..288285d985 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -23,6 +23,7 @@ namespace Bit.Core.Services Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task GetTwoFactorAsync(User user, Enums.TwoFactorProviderType provider); Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); + Task GenerateUserTokenAsync(User user, string tokenProvider, string purpose); Task DeleteAsync(User user); } } diff --git a/src/Core/Services/Implementations/NoopMailService.cs b/src/Core/Services/Implementations/NoopMailService.cs index 3117636a5c..988e067ce3 100644 --- a/src/Core/Services/Implementations/NoopMailService.cs +++ b/src/Core/Services/Implementations/NoopMailService.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Bit.Core.Models.Table; namespace Bit.Core.Services @@ -25,6 +26,11 @@ namespace Bit.Core.Services return Task.FromResult(0); } + public Task SendOrganizationInviteEmailAsync(string organizationName, string email, string token) + { + return Task.FromResult(0); + } + public Task SendWelcomeEmailAsync(User user) { return Task.FromResult(0); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index b193cf7dbd..06fdceb5a2 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -7,6 +7,7 @@ using Bit.Core.Models.Table; using Bit.Core.Utilities; using Bit.Core.Exceptions; using System.Collections.Generic; +using Microsoft.AspNetCore.DataProtection; namespace Bit.Core.Services { @@ -17,19 +18,25 @@ namespace Bit.Core.Services private readonly ISubvaultRepository _subvaultRepository; private readonly ISubvaultUserRepository _subvaultUserRepository; private readonly IUserRepository _userRepository; + private readonly IDataProtector _dataProtector; + private readonly IMailService _mailService; public OrganizationService( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ISubvaultRepository subvaultRepository, ISubvaultUserRepository subvaultUserRepository, - IUserRepository userRepository) + IUserRepository userRepository, + IDataProtectionProvider dataProtectionProvider, + IMailService mailService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _subvaultRepository = subvaultRepository; _subvaultUserRepository = subvaultUserRepository; _userRepository = userRepository; + _dataProtector = dataProtectionProvider.CreateProtector("OrganizationServiceDataProtector"); + _mailService = mailService; } public async Task> SignUpAsync(OrganizationSignup signup) @@ -90,9 +97,18 @@ namespace Bit.Core.Services } } - public async Task InviteUserAsync(Guid organizationId, string email, Enums.OrganizationUserType type, - IEnumerable subvaults) + public async Task InviteUserAsync(Guid organizationId, Guid invitingUserId, string email, + Enums.OrganizationUserType type, IEnumerable subvaults) { + if(!(await OrganizationUserHasAdminRightsAsync(organizationId, invitingUserId))) + { + throw new BadRequestException("Cannot invite users."); + } + + // TODO: make sure user is not already invited + + // TODO: validate subvaults? + var orgUser = new OrganizationUser { OrganizationId = organizationId, @@ -107,21 +123,70 @@ namespace Bit.Core.Services await _organizationUserRepository.CreateAsync(orgUser); await SaveUserSubvaultsAsync(orgUser, subvaults, true); - - // TODO: send email + await SendInviteAsync(organizationId, email); return orgUser; } + public async Task ResendInviteAsync(Guid organizationId, Guid invitingUserId, Guid organizationUserId) + { + if(!(await OrganizationUserHasAdminRightsAsync(organizationId, invitingUserId))) + { + throw new BadRequestException("Cannot invite users."); + } + + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if(orgUser == null || orgUser.OrganizationId != organizationId || + orgUser.Status == Enums.OrganizationUserStatusType.Invited) + { + throw new BadRequestException("User invalid."); + } + + await SendInviteAsync(organizationId, orgUser.Email); + } + + private async Task SendInviteAsync(Guid organizationId, string email) + { + var token = _dataProtector.Protect( + $"OrganizationInvite {organizationId} {email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + await _mailService.SendOrganizationInviteEmailAsync("Organization Name", email, token); + } + public async Task AcceptUserAsync(Guid organizationUserId, User user, string token) { var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if(orgUser.Email != user.Email) + if(orgUser == null || orgUser.Email != user.Email) { throw new BadRequestException("User invalid."); } - // TODO: validate token + if(orgUser.Status != Enums.OrganizationUserStatusType.Invited) + { + throw new BadRequestException("Already accepted."); + } + + var tokenValidationFailed = true; + try + { + var unprotectedData = _dataProtector.Unprotect(token); + var dataParts = unprotectedData.Split(' '); + if(dataParts.Length == 4 && dataParts[0] == "OrganizationInvite" && + new Guid(dataParts[1]) == orgUser.OrganizationId && dataParts[2] == user.Email) + { + var creationTime = CoreHelpers.FromEpocMilliseconds(Convert.ToInt64(dataParts[3])); + tokenValidationFailed = creationTime.AddDays(5) < DateTime.UtcNow; + } + } + catch + { + tokenValidationFailed = true; + } + + if(tokenValidationFailed) + { + throw new BadRequestException("Invalid token."); + } orgUser.Status = Enums.OrganizationUserStatusType.Accepted; orgUser.UserId = orgUser.Id; @@ -133,12 +198,19 @@ namespace Bit.Core.Services return orgUser; } - public async Task ConfirmUserAsync(Guid organizationUserId, string key) + public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, + Guid confirmingUserId) { - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if(orgUser.Status != Enums.OrganizationUserStatusType.Accepted) + if(!(await OrganizationUserHasAdminRightsAsync(organizationId, confirmingUserId))) { - throw new BadRequestException("User not accepted."); + throw new BadRequestException("Cannot confirm users."); + } + + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if(orgUser == null || orgUser.Status != Enums.OrganizationUserStatusType.Accepted || + orgUser.OrganizationId != organizationId) + { + throw new BadRequestException("User not valid."); } orgUser.Status = Enums.OrganizationUserStatusType.Confirmed; @@ -151,17 +223,52 @@ namespace Bit.Core.Services return orgUser; } - public async Task SaveUserAsync(OrganizationUser user, IEnumerable subvaults) + public async Task SaveUserAsync(OrganizationUser user, Guid savingUserId, IEnumerable subvaults) { if(user.Id.Equals(default(Guid))) { throw new BadRequestException("Invite the user first."); } + if(!(await OrganizationUserHasAdminRightsAsync(user.OrganizationId, savingUserId))) + { + throw new BadRequestException("Cannot update users."); + } + + // TODO: validate subvaults? + await _organizationUserRepository.ReplaceAsync(user); await SaveUserSubvaultsAsync(user, subvaults, false); } + public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId) + { + if(!(await OrganizationUserHasAdminRightsAsync(organizationId, deletingUserId))) + { + throw new BadRequestException("Cannot delete users."); + } + + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if(orgUser == null || orgUser.OrganizationId != organizationId) + { + throw new BadRequestException("User not valid."); + } + + await _organizationUserRepository.DeleteAsync(orgUser); + } + + private async Task OrganizationUserHasAdminRightsAsync(Guid organizationId, Guid userId) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + if(orgUser == null) + { + return false; + } + + return orgUser.Status == Enums.OrganizationUserStatusType.Confirmed && + orgUser.Type != Enums.OrganizationUserType.User; + } + private async Task SaveUserSubvaultsAsync(OrganizationUser user, IEnumerable subvaults, bool newUser) { if(subvaults == null) diff --git a/src/Core/Services/Implementations/SendGridMailService.cs b/src/Core/Services/Implementations/SendGridMailService.cs index ee1619ccc3..930c83ef29 100644 --- a/src/Core/Services/Implementations/SendGridMailService.cs +++ b/src/Core/Services/Implementations/SendGridMailService.cs @@ -14,6 +14,7 @@ namespace Bit.Core.Services private const string ChangeEmailTemplateId = "ec2c1471-8292-4f17-b6b6-8223d514f86e"; private const string NoMasterPasswordHintTemplateId = "136eb299-e102-495a-88bd-f96736eea159"; private const string MasterPasswordHintTemplateId = "be77cfde-95dd-4cb9-b5e0-8286b53885f1"; + private const string OrganizationInviteTemplateId = "1eff5512-e36c-49a8-b9e2-2b215d6bbced"; private const string AdministrativeCategoryName = "Administrative"; private const string MarketingCategoryName = "Marketing"; @@ -87,6 +88,19 @@ namespace Bit.Core.Services await _client.SendEmailAsync(message); } + public async Task SendOrganizationInviteEmailAsync(string organizationName, string email, string token) + { + var message = CreateDefaultMessage(OrganizationInviteTemplateId); + + message.Subject = $"Join {organizationName}"; + message.AddTo(new EmailAddress(email)); + message.AddSubstitution("{{organizationName}}", organizationName); + message.AddSubstitution("{{token}}", token); + message.AddCategories(new List { AdministrativeCategoryName, "Organization Invite" }); + + await _client.SendEmailAsync(message); + } + private SendGridMessage CreateDefaultMessage(string templateId) { var message = new SendGridMessage diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index e411041403..46c3bafe03 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -84,9 +84,14 @@ namespace Bit.Core.Utilities return cert; } - public static long EpocMilliseconds(DateTime date) + public static long ToEpocMilliseconds(DateTime date) { return (long)Math.Round((date - _epoc).TotalMilliseconds, 0); } + + public static DateTime FromEpocMilliseconds(long milliseconds) + { + return _epoc.AddMilliseconds(milliseconds); + } } }