From 437b9710039ddbcd5ba1fcd3cfc457082feb4170 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 8 Dec 2015 22:57:38 -0500 Subject: [PATCH] initial commit of source --- .gitignore | 199 +++++++++++ NuGet.Config | 8 + README.md | 1 + bitwarden.sln | 42 +++ global.json | 6 + src/Api/Api.xproj | 19 ++ src/Api/Controllers/AccountsController.cs | 210 ++++++++++++ src/Api/Controllers/AuthController.cs | 62 ++++ src/Api/Controllers/FoldersController.cs | 78 +++++ src/Api/Controllers/SitesController.cs | 148 +++++++++ .../Request/Accounts/EmailRequestModel.cs | 19 ++ .../Accounts/EmailTokenRequestModel.cs | 13 + .../Accounts/PasswordHintRequestModel.cs | 11 + .../Request/Accounts/PasswordRequestModel.cs | 14 + .../Request/Accounts/RegisterRequestModel.cs | 29 ++ .../Accounts/RegisterTokenRequestModel.cs | 11 + .../Accounts/SecurityStampRequestModel.cs | 10 + .../Accounts/UpdateProfileRequestModel.cs | 24 ++ .../Accounts/UpdateTwoFactorRequestModel.cs | 22 ++ .../Request/Auth/AuthTokenRequestModel.cs | 13 + .../Auth/AuthTokenTwoFactorRequestModel.cs | 12 + .../Request/Ciphers/CipherRequestModel.cs | 85 +++++ .../Request/Folders/FolderRequestModel.cs | 30 ++ .../Models/Request/Sites/SiteRequestModel.cs | 52 +++ .../Models/Response/AuthTokenResponseModel.cs | 18 + src/Api/Models/Response/ErrorResponseModel.cs | 51 +++ .../Models/Response/FolderResponseModel.cs | 23 ++ src/Api/Models/Response/ListResponseModel.cs | 16 + .../Models/Response/ProfileResponseModel.cs | 31 ++ src/Api/Models/Response/ResponseModel.cs | 19 ++ src/Api/Models/Response/SiteResponseModel.cs | 36 ++ .../Models/Response/TwoFactorResponseModel.cs | 26 ++ src/Api/Properties/AssemblyInfo.cs | 23 ++ src/Api/Properties/launchSettings.json | 25 ++ src/Api/Startup.cs | 157 +++++++++ src/Api/Utilities/EncryptedValueAttribute.cs | 52 +++ .../ExceptionHandlerFilterAttribute.cs | 65 ++++ .../ModelStateValidationFilterAttribute.cs | 24 ++ src/Api/project.json | 42 +++ src/Api/settings.Production.json | 5 + src/Api/settings.Staging.json | 5 + src/Api/settings.json | 17 + src/Api/wwwroot/web.config | 9 + src/Core/Core.xproj | 18 + src/Core/CurrentContext.cs | 13 + src/Core/Domains/Cipher.cs | 23 ++ src/Core/Domains/Folder.cs | 9 + src/Core/Domains/Role.cs | 10 + src/Core/Domains/Site.cs | 16 + src/Core/Domains/User.cs | 30 ++ src/Core/Enums/CipherType.cs | 8 + src/Core/Enums/TwoFactorProvider.cs | 7 + src/Core/Exceptions/BadRequestException.cs | 30 ++ src/Core/Exceptions/NotFoundException.cs | 6 + src/Core/GlobalSettings.cs | 28 ++ src/Core/IDataObject.cs | 12 + .../Identity/AuthenticatorTokenProvider.cs | 43 +++ .../Identity/JwtBearerBuilderExtensions.cs | 62 ++++ .../Identity/JwtBearerEventImplementations.cs | 50 +++ src/Core/Identity/JwtBearerIdentityOptions.cs | 16 + ...arerIdentityServiceCollectionExtensions.cs | 54 +++ src/Core/Identity/JwtBearerSignInManager.cs | 160 +++++++++ src/Core/Identity/JwtBearerSignInResult.cs | 34 ++ .../LowerInvariantLookupNormalizer.cs | 12 + src/Core/Identity/RoleStore.cs | 64 ++++ src/Core/Identity/UserStore.cs | 160 +++++++++ src/Core/Properties/AssemblyInfo.cs | 23 ++ .../Repositories/DocumentDB/BaseRepository.cs | 67 ++++ .../DocumentDB/CipherRepository.cs | 40 +++ .../DocumentDB/FolderRepository.cs | 49 +++ .../Repositories/DocumentDB/Repository.cs | 76 +++++ .../Repositories/DocumentDB/SiteRepository.cs | 50 +++ .../bulkUpdateDirtyCiphers.js | 87 +++++ .../replaceUserAndDirtyCiphers.js | 107 ++++++ .../Repositories/DocumentDB/UserRepository.cs | 37 +++ .../Utilities/DocumentClientHelpers.cs | 50 +++ .../Utilities/ManagedHashPartitionResolver.cs | 35 ++ src/Core/Repositories/ICipherRepository.cs | 10 + src/Core/Repositories/IFolderRepository.cs | 13 + src/Core/Repositories/IRepository.cs | 14 + src/Core/Repositories/ISiteRepository.cs | 14 + src/Core/Repositories/IUserRepository.cs | 13 + src/Core/Services/IMailService.cs | 16 + src/Core/Services/IUserService.cs | 22 ++ src/Core/Services/MailService.cs | 138 ++++++++ src/Core/Services/UserService.cs | 308 ++++++++++++++++++ src/Core/project.json | 23 ++ 87 files changed, 3819 insertions(+) create mode 100644 .gitignore create mode 100644 NuGet.Config create mode 100644 README.md create mode 100644 bitwarden.sln create mode 100644 global.json create mode 100644 src/Api/Api.xproj create mode 100644 src/Api/Controllers/AccountsController.cs create mode 100644 src/Api/Controllers/AuthController.cs create mode 100644 src/Api/Controllers/FoldersController.cs create mode 100644 src/Api/Controllers/SitesController.cs create mode 100644 src/Api/Models/Request/Accounts/EmailRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/EmailTokenRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/PasswordHintRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/PasswordRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/RegisterRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/RegisterTokenRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/SecurityStampRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/UpdateProfileRequestModel.cs create mode 100644 src/Api/Models/Request/Accounts/UpdateTwoFactorRequestModel.cs create mode 100644 src/Api/Models/Request/Auth/AuthTokenRequestModel.cs create mode 100644 src/Api/Models/Request/Auth/AuthTokenTwoFactorRequestModel.cs create mode 100644 src/Api/Models/Request/Ciphers/CipherRequestModel.cs create mode 100644 src/Api/Models/Request/Folders/FolderRequestModel.cs create mode 100644 src/Api/Models/Request/Sites/SiteRequestModel.cs create mode 100644 src/Api/Models/Response/AuthTokenResponseModel.cs create mode 100644 src/Api/Models/Response/ErrorResponseModel.cs create mode 100644 src/Api/Models/Response/FolderResponseModel.cs create mode 100644 src/Api/Models/Response/ListResponseModel.cs create mode 100644 src/Api/Models/Response/ProfileResponseModel.cs create mode 100644 src/Api/Models/Response/ResponseModel.cs create mode 100644 src/Api/Models/Response/SiteResponseModel.cs create mode 100644 src/Api/Models/Response/TwoFactorResponseModel.cs create mode 100644 src/Api/Properties/AssemblyInfo.cs create mode 100644 src/Api/Properties/launchSettings.json create mode 100644 src/Api/Startup.cs create mode 100644 src/Api/Utilities/EncryptedValueAttribute.cs create mode 100644 src/Api/Utilities/ExceptionHandlerFilterAttribute.cs create mode 100644 src/Api/Utilities/ModelStateValidationFilterAttribute.cs create mode 100644 src/Api/project.json create mode 100644 src/Api/settings.Production.json create mode 100644 src/Api/settings.Staging.json create mode 100644 src/Api/settings.json create mode 100644 src/Api/wwwroot/web.config create mode 100644 src/Core/Core.xproj create mode 100644 src/Core/CurrentContext.cs create mode 100644 src/Core/Domains/Cipher.cs create mode 100644 src/Core/Domains/Folder.cs create mode 100644 src/Core/Domains/Role.cs create mode 100644 src/Core/Domains/Site.cs create mode 100644 src/Core/Domains/User.cs create mode 100644 src/Core/Enums/CipherType.cs create mode 100644 src/Core/Enums/TwoFactorProvider.cs create mode 100644 src/Core/Exceptions/BadRequestException.cs create mode 100644 src/Core/Exceptions/NotFoundException.cs create mode 100644 src/Core/GlobalSettings.cs create mode 100644 src/Core/IDataObject.cs create mode 100644 src/Core/Identity/AuthenticatorTokenProvider.cs create mode 100644 src/Core/Identity/JwtBearerBuilderExtensions.cs create mode 100644 src/Core/Identity/JwtBearerEventImplementations.cs create mode 100644 src/Core/Identity/JwtBearerIdentityOptions.cs create mode 100644 src/Core/Identity/JwtBearerIdentityServiceCollectionExtensions.cs create mode 100644 src/Core/Identity/JwtBearerSignInManager.cs create mode 100644 src/Core/Identity/JwtBearerSignInResult.cs create mode 100644 src/Core/Identity/LowerInvariantLookupNormalizer.cs create mode 100644 src/Core/Identity/RoleStore.cs create mode 100644 src/Core/Identity/UserStore.cs create mode 100644 src/Core/Properties/AssemblyInfo.cs create mode 100644 src/Core/Repositories/DocumentDB/BaseRepository.cs create mode 100644 src/Core/Repositories/DocumentDB/CipherRepository.cs create mode 100644 src/Core/Repositories/DocumentDB/FolderRepository.cs create mode 100644 src/Core/Repositories/DocumentDB/Repository.cs create mode 100644 src/Core/Repositories/DocumentDB/SiteRepository.cs create mode 100644 src/Core/Repositories/DocumentDB/Stored Procedures/bulkUpdateDirtyCiphers.js create mode 100644 src/Core/Repositories/DocumentDB/Stored Procedures/replaceUserAndDirtyCiphers.js create mode 100644 src/Core/Repositories/DocumentDB/UserRepository.cs create mode 100644 src/Core/Repositories/DocumentDB/Utilities/DocumentClientHelpers.cs create mode 100644 src/Core/Repositories/DocumentDB/Utilities/ManagedHashPartitionResolver.cs create mode 100644 src/Core/Repositories/ICipherRepository.cs create mode 100644 src/Core/Repositories/IFolderRepository.cs create mode 100644 src/Core/Repositories/IRepository.cs create mode 100644 src/Core/Repositories/ISiteRepository.cs create mode 100644 src/Core/Repositories/IUserRepository.cs create mode 100644 src/Core/Services/IMailService.cs create mode 100644 src/Core/Services/IUserService.cs create mode 100644 src/Core/Services/MailService.cs create mode 100644 src/Core/Services/UserService.cs create mode 100644 src/Core/project.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..9dd28bcc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,199 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studo 2015 cache/options directory +.vs/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.[Cc]ache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Other +project.lock.json \ No newline at end of file diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 000000000..c3c2967a3 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 000000000..b74b9c93e --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# bitwarden diff --git a/bitwarden.sln b/bitwarden.sln new file mode 100644 index 000000000..9da5cfe97 --- /dev/null +++ b/bitwarden.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.23107.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}" + ProjectSection(SolutionItems) = preProject + .gitignore = .gitignore + global.json = global.json + NuGet.Config = NuGet.Config + README.md = README.md + EndProjectSection +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Core", "src\Core\Core.xproj", "{3973D21B-A692-4B60-9B70-3631C057423A}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Api", "src\Api\Api.xproj", "{E8548AD6-7FB0-439A-8EB5-549A10336D2D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3973D21B-A692-4B60-9B70-3631C057423A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3973D21B-A692-4B60-9B70-3631C057423A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3973D21B-A692-4B60-9B70-3631C057423A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3973D21B-A692-4B60-9B70-3631C057423A}.Release|Any CPU.Build.0 = Release|Any CPU + {E8548AD6-7FB0-439A-8EB5-549A10336D2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8548AD6-7FB0-439A-8EB5-549A10336D2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8548AD6-7FB0-439A-8EB5-549A10336D2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8548AD6-7FB0-439A-8EB5-549A10336D2D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {3973D21B-A692-4B60-9B70-3631C057423A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} + {E8548AD6-7FB0-439A-8EB5-549A10336D2D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} + EndGlobalSection +EndGlobal diff --git a/global.json b/global.json new file mode 100644 index 000000000..38c762a32 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "projects": [ "src", "test" ], + "sdk": { + "version": "1.0.0-rc1-final" + } +} diff --git a/src/Api/Api.xproj b/src/Api/Api.xproj new file mode 100644 index 000000000..21b540470 --- /dev/null +++ b/src/Api/Api.xproj @@ -0,0 +1,19 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + e8548ad6-7fb0-439a-8eb5-549a10336d2d + Bit.Api + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + 4000 + + + \ No newline at end of file diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs new file mode 100644 index 000000000..7c27e5a12 --- /dev/null +++ b/src/Api/Controllers/AccountsController.cs @@ -0,0 +1,210 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNet.Authorization; +using Microsoft.AspNet.DataProtection; +using Microsoft.AspNet.Mvc; +using Bit.Api.Models; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Microsoft.AspNet.Identity; +using Bit.Core.Domains; +using Bit.Core.Enums; +using Bit.Core; +using System.Security.Claims; + +namespace Bit.Api.Controllers +{ + [Route("accounts")] + [Authorize("Application")] + public class AccountsController : Controller + { + private readonly IUserService _userService; + private readonly UserManager _userManager; + private readonly CurrentContext _currentContext; + + public AccountsController( + IDataProtectionProvider dataProtectionProvider, + IUserService userService, + UserManager userManager, + CurrentContext currentContext) + { + _userService = userService; + _userManager = userManager; + _currentContext = currentContext; + } + + [HttpPost("register-token")] + [AllowAnonymous] + public async Task PostRegisterToken([FromBody]RegisterTokenRequestModel model) + { + await _userService.InitiateRegistrationAsync(model.Email); + } + + [HttpPost("register")] + [AllowAnonymous] + public async Task PostRegister([FromBody]RegisterRequestModel model) + { + var result = await _userService.RegisterUserAsync(model.Token, model.ToUser(), model.MasterPasswordHash); + if(result.Succeeded) + { + return; + } + + foreach(var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + await Task.Delay(2000); + throw new BadRequestException(ModelState); + } + + [HttpPost("password-hint")] + [AllowAnonymous] + public async Task PostPasswordHint([FromBody]PasswordHintRequestModel model) + { + await _userService.SendMasterPasswordHintAsync(model.Email); + } + + [HttpPost("email-token")] + public async Task PostEmailToken([FromBody]EmailTokenRequestModel model) + { + if(!await _userManager.CheckPasswordAsync(_currentContext.User, model.MasterPasswordHash)) + { + await Task.Delay(2000); + throw new BadRequestException("MasterPasswordHash", "Invalid password."); + } + + await _userService.InitiateEmailChangeAsync(_currentContext.User, model.NewEmail); + } + + [HttpPut("email")] + public async Task PutEmail([FromBody]EmailRequestModel model) + { + // NOTE: It is assumed that the eventual repository call will make sure the updated + // ciphers belong to user making this call. Therefore, no check is done here. + var ciphers = CipherRequestModel.ToDynamicCiphers(model.Ciphers, User.GetUserId()); + + var result = await _userService.ChangeEmailAsync( + _currentContext.User, + model.MasterPasswordHash, + model.NewEmail, + model.NewMasterPasswordHash, + model.Token, + ciphers); + + if(result.Succeeded) + { + return; + } + + foreach(var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + await Task.Delay(2000); + throw new BadRequestException(ModelState); + } + + [HttpPut("password")] + public async Task PutPassword([FromBody]PasswordRequestModel model) + { + // NOTE: It is assumed that the eventual repository call will make sure the updated + // ciphers belong to user making this call. Therefore, no check is done here. + var ciphers = CipherRequestModel.ToDynamicCiphers(model.Ciphers, User.GetUserId()); + + var result = await _userService.ChangePasswordAsync( + _currentContext.User, + model.MasterPasswordHash, + model.NewMasterPasswordHash, + ciphers); + + if(result.Succeeded) + { + return; + } + + foreach(var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + await Task.Delay(2000); + throw new BadRequestException(ModelState); + } + + [HttpPut("security-stamp")] + public async Task PutSecurityStamp([FromBody]SecurityStampRequestModel model) + { + var result = await _userService.RefreshSecurityStampAsync(_currentContext.User, model.MasterPasswordHash); + if(result.Succeeded) + { + return; + } + + foreach(var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + await Task.Delay(2000); + throw new BadRequestException(ModelState); + } + + [HttpGet("profile")] + public Task GetProfile() + { + var response = new ProfileResponseModel(_currentContext.User); + return Task.FromResult(response); + } + + [HttpPut("profile")] + public async Task PutProfile([FromBody]UpdateProfileRequestModel model) + { + await _userService.SaveUserAsync(model.ToUser(_currentContext.User)); + + var response = new ProfileResponseModel(_currentContext.User); + return response; + } + + [HttpGet("two-factor")] + public async Task GetTwoFactor(string masterPasswordHash, TwoFactorProvider provider) + { + var user = _currentContext.User; + if(!await _userManager.CheckPasswordAsync(user, masterPasswordHash)) + { + await Task.Delay(2000); + throw new BadRequestException("MasterPasswordHash", "Invalid password."); + } + + await _userService.GetTwoFactorAsync(user, provider); + + var response = new TwoFactorResponseModel(user); + return response; + } + + [HttpPut("two-factor")] + public async Task PutTwoFactor([FromBody]UpdateTwoFactorRequestModel model) + { + var user = _currentContext.User; + if(!await _userManager.CheckPasswordAsync(user, model.MasterPasswordHash)) + { + await Task.Delay(2000); + throw new BadRequestException("MasterPasswordHash", "Invalid password."); + } + + if(model.Enabled.Value && !await _userManager.VerifyTwoFactorTokenAsync(user, "Authenticator", model.Token)) + { + await Task.Delay(2000); + throw new BadRequestException("Token", "Invalid token."); + } + + user.TwoFactorEnabled = model.Enabled.Value; + await _userService.SaveUserAsync(user); + + var response = new TwoFactorResponseModel(user); + return response; + } + } +} diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs new file mode 100644 index 000000000..7e04b81a3 --- /dev/null +++ b/src/Api/Controllers/AuthController.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNet.Mvc; +using Bit.Core.Identity; +using Bit.Core.Repositories; +using Bit.Api.Models; +using Microsoft.AspNet.Authorization; +using Bit.Core.Exceptions; +using Bit.Core; + +namespace Bit.Api.Controllers +{ + [Route("auth")] + public class AuthController : Controller + { + private readonly JwtBearerSignInManager _signInManager; + private readonly IUserRepository _userRepository; + private readonly CurrentContext _currentContext; + + public AuthController( + JwtBearerSignInManager signInManager, + IUserRepository userRepository, + CurrentContext currentContext) + { + _signInManager = signInManager; + _userRepository = userRepository; + _currentContext = currentContext; + } + + [HttpPost("token")] + [AllowAnonymous] + public async Task PostToken([FromBody]AuthTokenRequestModel model) + { + var result = await _signInManager.PasswordSignInAsync(model.Email.ToLower(), model.MasterPasswordHash); + if(result == JwtBearerSignInResult.Success) + { + return new AuthTokenResponseModel(result.Token, result.User); + } + else if(result == JwtBearerSignInResult.TwoFactorRequired) + { + return new AuthTokenResponseModel(result.Token, null); + } + + await Task.Delay(2000); + throw new BadRequestException("Username or password is incorrect. Try again."); + } + + [HttpPost("token/two-factor")] + [Authorize("TwoFactor")] + public async Task PostTokenTwoFactor([FromBody]AuthTokenTwoFactorRequestModel model) + { + var result = await _signInManager.TwoFactorSignInAsync(_currentContext.User, model.Provider, model.Code); + if(result == JwtBearerSignInResult.Success) + { + return new AuthTokenResponseModel(result.Token, result.User); + } + + await Task.Delay(2000); + throw new BadRequestException("Code is not correct. Try again."); + } + } +} diff --git a/src/Api/Controllers/FoldersController.cs b/src/Api/Controllers/FoldersController.cs new file mode 100644 index 000000000..2e9ef578d --- /dev/null +++ b/src/Api/Controllers/FoldersController.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.Mvc; +using Bit.Core.Repositories; +using System.Security.Claims; +using Microsoft.AspNet.Authorization; +using Bit.Api.Models; +using Bit.Core.Exceptions; + +namespace Bit.Api.Controllers +{ + [Route("folders")] + [Authorize("Application")] + public class FoldersController : Controller + { + private readonly IFolderRepository _folderRepository; + + public FoldersController( + IFolderRepository folderRepository) + { + _folderRepository = folderRepository; + } + + [HttpGet("{id}")] + public async Task Get(string id) + { + var folder = await _folderRepository.GetByIdAsync(id, User.GetUserId()); + if(folder == null) + { + throw new NotFoundException(); + } + + return new FolderResponseModel(folder); + } + + [HttpGet("")] + public async Task> Get(bool dirty = false) + { + var folders = await _folderRepository.GetManyByUserIdAsync(User.GetUserId()); + return new ListResponseModel(folders.Select(f => new FolderResponseModel(f))); + } + + [HttpPost("")] + public async Task Post([FromBody]FolderRequestModel model) + { + var folder = model.ToFolder(User.GetUserId()); + await _folderRepository.CreateAsync(folder); + return new FolderResponseModel(folder); + } + + [HttpPut("{id}")] + public async Task Put(string id, [FromBody]FolderRequestModel model) + { + var folder = await _folderRepository.GetByIdAsync(id, User.GetUserId()); + if(folder == null) + { + throw new NotFoundException(); + } + + await _folderRepository.ReplaceAsync(model.ToFolder(folder)); + return new FolderResponseModel(folder); + } + + [HttpDelete("{id}")] + public async Task Delete(string id) + { + var folder = await _folderRepository.GetByIdAsync(id, User.GetUserId()); + if(folder == null) + { + throw new NotFoundException(); + } + + await _folderRepository.DeleteAsync(folder); + } + } +} diff --git a/src/Api/Controllers/SitesController.cs b/src/Api/Controllers/SitesController.cs new file mode 100644 index 000000000..0dd98eaf6 --- /dev/null +++ b/src/Api/Controllers/SitesController.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.Mvc; +using Bit.Core.Repositories; +using System.Security.Claims; +using Microsoft.AspNet.Authorization; +using Bit.Api.Models; +using Bit.Core.Exceptions; +using Bit.Core.Domains; + +namespace Bit.Api.Controllers +{ + [Route("sites")] + [Authorize("Application")] + public class SitesController : Controller + { + private readonly ISiteRepository _siteRepository; + private readonly IFolderRepository _folderRepository; + + public SitesController( + ISiteRepository siteRepository, + IFolderRepository folderRepository) + { + _siteRepository = siteRepository; + _folderRepository = folderRepository; + } + + [HttpGet("{id}")] + public async Task Get(string id, string[] expand = null) + { + var site = await _siteRepository.GetByIdAsync(id, User.GetUserId()); + if(site == null) + { + throw new NotFoundException(); + } + + var response = new SiteResponseModel(site); + await ExpandAsync(site, response, expand, null); + return response; + } + + [HttpGet("")] + public async Task> Get(bool dirty = false, string[] expand = null) + { + var sites = await _siteRepository.GetManyByUserIdAsync(User.GetUserId(), dirty); + + var responses = sites.Select(s => new SiteResponseModel(s)); + await ExpandManyAsync(sites, responses, expand, null); + return new ListResponseModel(responses); + } + + [HttpPost("")] + public async Task Post([FromBody]SiteRequestModel model, string[] expand = null) + { + var site = model.ToSite(User.GetUserId()); + await _siteRepository.CreateAsync(site); + + var response = new SiteResponseModel(site); + await ExpandAsync(site, response, expand, null); + return response; + } + + [HttpPut("{id}")] + public async Task Put(string id, [FromBody]SiteRequestModel model, string[] expand = null) + { + var site = await _siteRepository.GetByIdAsync(id, User.GetUserId()); + if(site == null) + { + throw new NotFoundException(); + } + + await _siteRepository.ReplaceAsync(model.ToSite(site)); + + var response = new SiteResponseModel(site); + await ExpandAsync(site, response, expand, null); + return response; + } + + [HttpDelete("{id}")] + public async Task Delete(string id) + { + var site = await _siteRepository.GetByIdAsync(id, User.GetUserId()); + if(site == null) + { + throw new NotFoundException(); + } + + await _siteRepository.DeleteAsync(site); + } + + private async Task ExpandAsync(Site site, SiteResponseModel response, string[] expand, Folder folder) + { + if(expand == null || expand.Count() == 0) + { + return; + } + + if(expand.Any(e => e.ToLower() == "folder")) + { + if(folder == null) + { + folder = await _folderRepository.GetByIdAsync(site.FolderId); + } + + response.Folder = new FolderResponseModel(folder); + } + } + + private async Task ExpandManyAsync(IEnumerable sites, IEnumerable responses, string[] expand, IEnumerable folders) + { + if(expand == null || expand.Count() == 0) + { + return; + } + + if(expand.Any(e => e.ToLower() == "folder")) + { + if(folders == null) + { + folders = await _folderRepository.GetManyByUserIdAsync(User.GetUserId()); + } + + if(folders != null && folders.Count() > 0) + { + foreach(var response in responses) + { + var site = sites.SingleOrDefault(s => s.Id == response.Id); + if(site == null) + { + continue; + } + + var folder = folders.SingleOrDefault(f => f.Id == site.FolderId); + if(folder == null) + { + continue; + } + + response.Folder = new FolderResponseModel(folder); + } + } + } + } + } + +} diff --git a/src/Api/Models/Request/Accounts/EmailRequestModel.cs b/src/Api/Models/Request/Accounts/EmailRequestModel.cs new file mode 100644 index 000000000..c9de5aeed --- /dev/null +++ b/src/Api/Models/Request/Accounts/EmailRequestModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class EmailRequestModel + { + [Required] + [EmailAddress] + public string NewEmail { get; set; } + [Required] + public string MasterPasswordHash { get; set; } + [Required] + public string NewMasterPasswordHash { get; set; } + [Required] + public string Token { get; set; } + [Required] + public CipherRequestModel[] Ciphers { get; set; } + } +} diff --git a/src/Api/Models/Request/Accounts/EmailTokenRequestModel.cs b/src/Api/Models/Request/Accounts/EmailTokenRequestModel.cs new file mode 100644 index 000000000..12a222864 --- /dev/null +++ b/src/Api/Models/Request/Accounts/EmailTokenRequestModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class EmailTokenRequestModel + { + [Required] + [EmailAddress] + public string NewEmail { get; set; } + [Required] + public string MasterPasswordHash { get; set; } + } +} diff --git a/src/Api/Models/Request/Accounts/PasswordHintRequestModel.cs b/src/Api/Models/Request/Accounts/PasswordHintRequestModel.cs new file mode 100644 index 000000000..d21051aa5 --- /dev/null +++ b/src/Api/Models/Request/Accounts/PasswordHintRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class PasswordHintRequestModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + } +} diff --git a/src/Api/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Models/Request/Accounts/PasswordRequestModel.cs new file mode 100644 index 000000000..c634e5663 --- /dev/null +++ b/src/Api/Models/Request/Accounts/PasswordRequestModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class PasswordRequestModel + { + [Required] + public string MasterPasswordHash { get; set; } + [Required] + public string NewMasterPasswordHash { get; set; } + [Required] + public CipherRequestModel[] Ciphers { get; set; } + } +} diff --git a/src/Api/Models/Request/Accounts/RegisterRequestModel.cs b/src/Api/Models/Request/Accounts/RegisterRequestModel.cs new file mode 100644 index 000000000..e1ebf02f9 --- /dev/null +++ b/src/Api/Models/Request/Accounts/RegisterRequestModel.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class RegisterRequestModel + { + [Required] + public string Token { get; set; } + [Required] + public string Name { get; set; } + [Required] + [EmailAddress] + public string Email { get; set; } + [Required] + public string MasterPasswordHash { get; set; } + public string MasterPasswordHint { get; set; } + + public User ToUser() + { + return new User + { + Name = Name, + Email = Email, + MasterPasswordHint = MasterPasswordHint + }; + } + } +} diff --git a/src/Api/Models/Request/Accounts/RegisterTokenRequestModel.cs b/src/Api/Models/Request/Accounts/RegisterTokenRequestModel.cs new file mode 100644 index 000000000..9c79372cf --- /dev/null +++ b/src/Api/Models/Request/Accounts/RegisterTokenRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class RegisterTokenRequestModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + } +} diff --git a/src/Api/Models/Request/Accounts/SecurityStampRequestModel.cs b/src/Api/Models/Request/Accounts/SecurityStampRequestModel.cs new file mode 100644 index 000000000..2a4f96d21 --- /dev/null +++ b/src/Api/Models/Request/Accounts/SecurityStampRequestModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class SecurityStampRequestModel + { + [Required] + public string MasterPasswordHash { get; set; } + } +} diff --git a/src/Api/Models/Request/Accounts/UpdateProfileRequestModel.cs b/src/Api/Models/Request/Accounts/UpdateProfileRequestModel.cs new file mode 100644 index 000000000..504692a9f --- /dev/null +++ b/src/Api/Models/Request/Accounts/UpdateProfileRequestModel.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class UpdateProfileRequestModel + { + [Required] + public string Name { get; set; } + public string MasterPasswordHint { get; set; } + [Required] + [RegularExpression("^[a-z]{2}-[A-Z]{2}$")] + public string Culture { get; set; } + + public User ToUser(User existingUser) + { + existingUser.Name = Name; + existingUser.MasterPasswordHint = string.IsNullOrWhiteSpace(MasterPasswordHint) ? null : MasterPasswordHint; + existingUser.Culture = Culture; + + return existingUser; + } + } +} diff --git a/src/Api/Models/Request/Accounts/UpdateTwoFactorRequestModel.cs b/src/Api/Models/Request/Accounts/UpdateTwoFactorRequestModel.cs new file mode 100644 index 000000000..85593158f --- /dev/null +++ b/src/Api/Models/Request/Accounts/UpdateTwoFactorRequestModel.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class UpdateTwoFactorRequestModel : IValidatableObject + { + [Required] + public string MasterPasswordHash { get; set; } + [Required] + public bool? Enabled { get; set; } + public string Token { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if(Enabled.HasValue && Enabled.Value && string.IsNullOrWhiteSpace(Token)) + { + yield return new ValidationResult("Token is required.", new[] { "Token" }); + } + } + } +} diff --git a/src/Api/Models/Request/Auth/AuthTokenRequestModel.cs b/src/Api/Models/Request/Auth/AuthTokenRequestModel.cs new file mode 100644 index 000000000..c80d35898 --- /dev/null +++ b/src/Api/Models/Request/Auth/AuthTokenRequestModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class AuthTokenRequestModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + [Required] + public string MasterPasswordHash { get; set; } + } +} diff --git a/src/Api/Models/Request/Auth/AuthTokenTwoFactorRequestModel.cs b/src/Api/Models/Request/Auth/AuthTokenTwoFactorRequestModel.cs new file mode 100644 index 000000000..f059aa196 --- /dev/null +++ b/src/Api/Models/Request/Auth/AuthTokenTwoFactorRequestModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class AuthTokenTwoFactorRequestModel + { + [Required] + public string Code { get; set; } + [Required] + public string Provider { get; set; } + } +} diff --git a/src/Api/Models/Request/Ciphers/CipherRequestModel.cs b/src/Api/Models/Request/Ciphers/CipherRequestModel.cs new file mode 100644 index 000000000..d3c391a75 --- /dev/null +++ b/src/Api/Models/Request/Ciphers/CipherRequestModel.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Bit.Api.Utilities; +using Bit.Core.Domains; +using System.Linq; +using Bit.Core.Enums; + +namespace Bit.Api.Models +{ + public class CipherRequestModel : IValidatableObject + { + public CipherType Type { get; set; } + + [Required] + public string Id { get; set; } + public string FolderId { get; set; } + [Required] + [EncryptedString] + public string Name { get; set; } + [EncryptedString] + public string Uri { get; set; } + [EncryptedString] + public string Username { get; set; } + [EncryptedString] + public string Password { get; set; } + [EncryptedString] + public string Notes { get; set; } + + public virtual Site ToSite(string userId = null) + { + return new Site + { + Id = Id, + UserId = userId, + FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : FolderId, + Name = Name, + Uri = Uri, + Username = Username, + Password = Password, + Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes + }; + } + + public Folder ToFolder(string userId = null) + { + return new Folder + { + Id = Id, + UserId = userId, + Name = Name + }; + } + + public static IEnumerable ToDynamicCiphers(CipherRequestModel[] models, string userId) + { + var sites = models.Where(m => m.Type == CipherType.Site).Select(m => m.ToSite(userId)).ToList(); + var folders = models.Where(m => m.Type == CipherType.Folder).Select(m => m.ToFolder(userId)).ToList(); + + var ciphers = new List(); + ciphers.AddRange(sites); + ciphers.AddRange(folders); + return ciphers; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if(Type == CipherType.Site) + { + if(string.IsNullOrWhiteSpace(Uri)) + { + yield return new ValidationResult("Uri is required for a site cypher.", new[] { "Uri" }); + } + if(string.IsNullOrWhiteSpace(Username)) + { + yield return new ValidationResult("Username is required for a site cypher.", new[] { "Username" }); + } + if(string.IsNullOrWhiteSpace(Password)) + { + yield return new ValidationResult("Password is required for a site cypher.", new[] { "Password" }); + } + } + } + } +} diff --git a/src/Api/Models/Request/Folders/FolderRequestModel.cs b/src/Api/Models/Request/Folders/FolderRequestModel.cs new file mode 100644 index 000000000..6dbb6b692 --- /dev/null +++ b/src/Api/Models/Request/Folders/FolderRequestModel.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Bit.Api.Utilities; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class FolderRequestModel + { + [Required] + [EncryptedString] + public string Name { get; set; } + + public Folder ToFolder(string userId = null) + { + return new Folder + { + UserId = userId, + Name = Name + }; + } + + public Folder ToFolder(Folder existingFolder) + { + existingFolder.Name = Name; + + return existingFolder; + } + } +} diff --git a/src/Api/Models/Request/Sites/SiteRequestModel.cs b/src/Api/Models/Request/Sites/SiteRequestModel.cs new file mode 100644 index 000000000..84d5994f6 --- /dev/null +++ b/src/Api/Models/Request/Sites/SiteRequestModel.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Bit.Api.Utilities; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class SiteRequestModel + { + public string FolderId { get; set; } + [Required] + [EncryptedString] + public string Name { get; set; } + [Required] + [EncryptedString] + public string Uri { get; set; } + [Required] + [EncryptedString] + public string Username { get; set; } + [Required] + [EncryptedString] + public string Password { get; set; } + [EncryptedString] + public string Notes { get; set; } + + public Site ToSite(string userId = null) + { + return new Site + { + UserId = userId, + FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : FolderId, + Name = Name, + Uri = Uri, + Username = Username, + Password = Password, + Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes + }; + } + + public Site ToSite(Site existingSite) + { + existingSite.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : FolderId; + existingSite.Name = Name; + existingSite.Uri = Uri; + existingSite.Username = Username; + existingSite.Password = Password; + existingSite.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes; + + return existingSite; + } + } +} diff --git a/src/Api/Models/Response/AuthTokenResponseModel.cs b/src/Api/Models/Response/AuthTokenResponseModel.cs new file mode 100644 index 000000000..bd740eb94 --- /dev/null +++ b/src/Api/Models/Response/AuthTokenResponseModel.cs @@ -0,0 +1,18 @@ +using System; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class AuthTokenResponseModel : ResponseModel + { + public AuthTokenResponseModel(string token, User user = null) + : base("authToken") + { + Token = token; + Profile = user == null ? null : new ProfileResponseModel(user); + } + + public string Token { get; set; } + public ProfileResponseModel Profile { get; set; } + } +} diff --git a/src/Api/Models/Response/ErrorResponseModel.cs b/src/Api/Models/Response/ErrorResponseModel.cs new file mode 100644 index 000000000..0a00718b1 --- /dev/null +++ b/src/Api/Models/Response/ErrorResponseModel.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Collections.Generic; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Bit.Api.Models.Response +{ + public class ErrorResponseModel : ResponseModel + { + public ErrorResponseModel() + : base("error") + { } + + public ErrorResponseModel(ModelStateDictionary modelState) + : this() + { + Message = "The model state is invalid."; + ValidationErrors = new Dictionary>(); + + var keys = modelState.Keys.ToList(); + var values = modelState.Values.ToList(); + + for(var i = 0; i < values.Count; i++) + { + var value = values[i]; + + if(keys.Count <= i) + { + // Keys not available for some reason. + break; + } + + var key = keys[i]; + + if(value.ValidationState != ModelValidationState.Invalid || value.Errors.Count == 0) + { + continue; + } + + var errors = value.Errors.Select(e => e.ErrorMessage); + ValidationErrors.Add(key, errors); + } + } + + public string Message { get; set; } + public Dictionary> ValidationErrors { get; set; } + // For use in development environments. + public string ExceptionMessage { get; set; } + public string ExceptionStackTrace { get; set; } + public string InnerExceptionMessage { get; set; } + } +} diff --git a/src/Api/Models/Response/FolderResponseModel.cs b/src/Api/Models/Response/FolderResponseModel.cs new file mode 100644 index 000000000..b02b44b0b --- /dev/null +++ b/src/Api/Models/Response/FolderResponseModel.cs @@ -0,0 +1,23 @@ +using System; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class FolderResponseModel : ResponseModel + { + public FolderResponseModel(Folder folder) + : base("folder") + { + if(folder == null) + { + throw new ArgumentNullException(nameof(folder)); + } + + Id = folder.Id; + Name = folder.Name; + } + + public string Id { get; set; } + public string Name { get; set; } + } +} diff --git a/src/Api/Models/Response/ListResponseModel.cs b/src/Api/Models/Response/ListResponseModel.cs new file mode 100644 index 000000000..db25e685e --- /dev/null +++ b/src/Api/Models/Response/ListResponseModel.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace Bit.Api.Models +{ + public class ListResponseModel : ResponseModel where T : ResponseModel + { + public ListResponseModel(IEnumerable data) + : base("list") + { + Data = data; + } + + public IEnumerable Data { get; set; } + } +} diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs new file mode 100644 index 000000000..207f3af2f --- /dev/null +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -0,0 +1,31 @@ +using System; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class ProfileResponseModel : ResponseModel + { + public ProfileResponseModel(User user) + : base("profile") + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + Id = user.Id; + Name = user.Name; + Email = user.Email; + MasterPasswordHint = string.IsNullOrWhiteSpace(user.MasterPasswordHint) ? null : user.MasterPasswordHint; + Culture = user.Culture; + TwoFactorEnabled = user.TwoFactorEnabled; + } + + public string Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string MasterPasswordHint { get; set; } + public string Culture { get; set; } + public bool TwoFactorEnabled { get; set; } + } +} diff --git a/src/Api/Models/Response/ResponseModel.cs b/src/Api/Models/Response/ResponseModel.cs new file mode 100644 index 000000000..d2a8d5793 --- /dev/null +++ b/src/Api/Models/Response/ResponseModel.cs @@ -0,0 +1,19 @@ +using System; + +namespace Bit.Api.Models +{ + public abstract class ResponseModel + { + public ResponseModel(string obj) + { + if(string.IsNullOrWhiteSpace(obj)) + { + throw new ArgumentNullException(nameof(obj)); + } + + Object = obj; + } + + public string Object { get; private set; } + } +} diff --git a/src/Api/Models/Response/SiteResponseModel.cs b/src/Api/Models/Response/SiteResponseModel.cs new file mode 100644 index 000000000..898286c26 --- /dev/null +++ b/src/Api/Models/Response/SiteResponseModel.cs @@ -0,0 +1,36 @@ +using System; +using Bit.Core.Domains; + +namespace Bit.Api.Models +{ + public class SiteResponseModel : ResponseModel + { + public SiteResponseModel(Site site) + : base("site") + { + if(site == null) + { + throw new ArgumentNullException(nameof(site)); + } + + Id = site.Id; + FolderId = string.IsNullOrWhiteSpace(site.FolderId) ? null : site.FolderId; + Name = site.Name; + Uri = site.Uri; + Username = site.Username; + Password = site.Password; + Notes = site.Notes; + } + + public string Id { get; set; } + public string FolderId { get; set; } + public string Name { get; set; } + public string Uri { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string Notes { get; set; } + + // Expandables + public FolderResponseModel Folder { get; set; } + } +} diff --git a/src/Api/Models/Response/TwoFactorResponseModel.cs b/src/Api/Models/Response/TwoFactorResponseModel.cs new file mode 100644 index 000000000..28d346af7 --- /dev/null +++ b/src/Api/Models/Response/TwoFactorResponseModel.cs @@ -0,0 +1,26 @@ +using System; +using Bit.Core.Domains; +using Bit.Core.Enums; + +namespace Bit.Api.Models +{ + public class TwoFactorResponseModel : ResponseModel + { + public TwoFactorResponseModel(User user) + : base("twoFactor") + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + TwoFactorEnabled = user.TwoFactorEnabled; + AuthenticatorKey = user.AuthenticatorKey; + TwoFactorProvider = user.TwoFactorProvider; + } + + public bool TwoFactorEnabled { get; set; } + public TwoFactorProvider? TwoFactorProvider { get; set; } + public string AuthenticatorKey { get; set; } + } +} diff --git a/src/Api/Properties/AssemblyInfo.cs b/src/Api/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..40aafd2b2 --- /dev/null +++ b/src/Api/Properties/AssemblyInfo.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Bit.Api")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("bitwarden")] +[assembly: AssemblyProduct("bitwarden")] +[assembly: AssemblyCopyright("Copyright © 2015")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("e8548ad6-7fb0-439a-8eb5-549a10336d2d")] diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json new file mode 100644 index 000000000..fdfa9a059 --- /dev/null +++ b/src/Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:4000", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "Hosting:Environment": "Development" + } + }, + "web": { + "commandName": "web", + "environmentVariables": { + "Hosting:Environment": "Development" + } + } + } +} \ No newline at end of file diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs new file mode 100644 index 000000000..0ab6b0858 --- /dev/null +++ b/src/Api/Startup.cs @@ -0,0 +1,157 @@ +using System; +using System.Security.Claims; +using Microsoft.AspNet.Authentication.JwtBearer; +using Microsoft.AspNet.Authorization; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Hosting; +using Microsoft.AspNet.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.OptionsModel; +using Bit.Api.Utilities; +using Bit.Core; +using Bit.Core.Domains; +using Bit.Core.Identity; +using Bit.Core.Repositories; +using Bit.Core.Repositories.DocumentDB.Utilities; +using Bit.Core.Services; +using Repos = Bit.Core.Repositories.DocumentDB; + +namespace Bit.Api +{ + public class Startup + { + public Startup(IHostingEnvironment env) + { + var builder = new ConfigurationBuilder() + .AddJsonFile("settings.json") + .AddJsonFile($"settings.{env.EnvironmentName}.json", optional: true); + + if(env.IsDevelopment()) + { + builder.AddUserSecrets(); + } + + builder.AddEnvironmentVariables(); + + Configuration = builder.Build(); + } + + public IConfigurationRoot Configuration { get; private set; } + + public void ConfigureServices(IServiceCollection services) + { + services.Configure(Configuration.GetSection("globalSettings")); + + // Options + services.AddOptions(); + + // Settings + var provider = services.BuildServiceProvider(); + var globalSettings = provider.GetRequiredService>().Value; + services.AddSingleton(s => globalSettings); + + // Repositories + var documentDBClient = DocumentClientHelpers.InitClient(globalSettings.DocumentDB); + services.AddSingleton(s => new Repos.UserRepository(documentDBClient, globalSettings.DocumentDB.DatabaseId)); + services.AddSingleton(s => new Repos.SiteRepository(documentDBClient, globalSettings.DocumentDB.DatabaseId)); + services.AddSingleton(s => new Repos.FolderRepository(documentDBClient, globalSettings.DocumentDB.DatabaseId)); + services.AddSingleton(s => new Repos.CipherRepository(documentDBClient, globalSettings.DocumentDB.DatabaseId)); + + // Context + services.AddScoped(); + + // Identity + services.AddTransient(); + services.AddJwtBearerIdentity(options => + { + options.User = new UserOptions + { + RequireUniqueEmail = true, + AllowedUserNameCharacters = null // all + }; + options.Password = new PasswordOptions + { + RequireDigit = false, + RequireLowercase = false, + RequiredLength = 8, + RequireNonLetterOrDigit = false, + RequireUppercase = false + }; + options.ClaimsIdentity = new ClaimsIdentityOptions + { + SecurityStampClaimType = "securitystamp", + UserNameClaimType = ClaimTypes.Email + }; + options.Tokens.ChangeEmailTokenProvider = TokenOptions.DefaultEmailProvider; + }, jwtBearerOptions => + { + jwtBearerOptions.Audience = "bitwarden"; + jwtBearerOptions.Issuer = "bitwarden"; + jwtBearerOptions.TokenLifetime = TimeSpan.FromDays(10 * 365); + jwtBearerOptions.TwoFactorTokenLifetime = TimeSpan.FromMinutes(10); + // TODO: Symmetric key + // waiting on https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/issues/250 + jwtBearerOptions.SigningCredentials = null; + }) + .AddUserStore() + .AddRoleStore() + .AddTokenProvider("Authenticator") + .AddTokenProvider>(TokenOptions.DefaultEmailProvider); + + var jwtIdentityOptions = provider.GetRequiredService>().Value; + services.AddAuthorization(auth => + { + auth.AddPolicy("Application", new AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme‌​) + .RequireAuthenticatedUser().RequireClaim(ClaimTypes.AuthenticationMethod, jwtIdentityOptions.AuthenticationMethod).Build()); + + auth.AddPolicy("TwoFactor", new AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser().RequireClaim(ClaimTypes.AuthenticationMethod, jwtIdentityOptions.TwoFactorAuthenticationMethod).Build()); + }); + + services.AddScoped(); + + // Services + services.AddSingleton(); + services.AddScoped(); + + // Cors + services.AddCors(o => o.AddPolicy("All", policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin())); + + // MVC + services.AddMvc(o => + { + o.Filters.Add(new ExceptionHandlerFilterAttribute()); + o.Filters.Add(new ModelStateValidationFilterAttribute()); + }); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) + { + loggerFactory.MinimumLevel = LogLevel.Information; + loggerFactory.AddConsole(); + loggerFactory.AddDebug(); + + // Add the platform handler to the request pipeline. + app.UseIISPlatformHandler(); + + // Add static files to the request pipeline. + app.UseStaticFiles(); + + // Add Cors + app.UseCors("All"); + + // Add Jwt authentication to the request pipeline. + app.UseJwtBearerIdentity(); + + // Add MVC to the request pipeline. + app.UseMvc(); + } + + // Entry point for the application. + public static void Main(string[] args) => WebApplication.Run(args); + } +} diff --git a/src/Api/Utilities/EncryptedValueAttribute.cs b/src/Api/Utilities/EncryptedValueAttribute.cs new file mode 100644 index 000000000..74d3b5c39 --- /dev/null +++ b/src/Api/Utilities/EncryptedValueAttribute.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Utilities +{ + /// + /// Validates a string that is in encrypted form: "b64iv=|b64ct=" + /// + public class EncryptedStringAttribute : ValidationAttribute + { + public EncryptedStringAttribute() + : base("{0} is not a valid encrypted string.") + { } + + public override bool IsValid(object value) + { + if(value == null) + { + return true; + } + + try + { + var encString = value?.ToString(); + if(string.IsNullOrWhiteSpace(encString)) + { + return false; + } + + var encStringPieces = encString.Split('|'); + if(encStringPieces.Length != 2) + { + return false; + } + + var iv = Convert.FromBase64String(encStringPieces[0]); + var ct = Convert.FromBase64String(encStringPieces[1]); + + if(iv.Length < 1 || ct.Length < 1) + { + return false; + } + } + catch + { + return false; + } + + return true; + } + } +} diff --git a/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs new file mode 100644 index 000000000..9a282fc30 --- /dev/null +++ b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs @@ -0,0 +1,65 @@ +using System; +using System.IdentityModel.Tokens; +using Bit.Api.Models.Response; +using Bit.Core.Exceptions; +using Microsoft.AspNet.Hosting; +using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Api.Utilities +{ + public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute + { + public override void OnException(ExceptionContext context) + { + var errorModel = new ErrorResponseModel { Message = "An error has occured." }; + + var exception = context.Exception; + if(exception == null) + { + // Should never happen. + return; + } + + var badRequestException = exception as BadRequestException; + if(badRequestException != null) + { + context.HttpContext.Response.StatusCode = 400; + + if(badRequestException != null) + { + errorModel = new ErrorResponseModel(badRequestException.ModelState); + } + else + { + errorModel.Message = badRequestException.Message; + } + } + else if(exception is ApplicationException) + { + context.HttpContext.Response.StatusCode = 402; + } + else if(exception is NotFoundException) + { + errorModel.Message = "Resource not found."; + context.HttpContext.Response.StatusCode = 404; + } + else + { + errorModel.Message = "An unhandled server error has occured."; + context.HttpContext.Response.StatusCode = 500; + } + + var env = context.HttpContext.ApplicationServices.GetRequiredService(); + if(env.IsDevelopment()) + { + errorModel.ExceptionMessage = exception.Message; + errorModel.ExceptionStackTrace = exception.StackTrace; + errorModel.InnerExceptionMessage = exception?.InnerException?.Message; + } + + context.Result = new ObjectResult(errorModel); + } + } +} diff --git a/src/Api/Utilities/ModelStateValidationFilterAttribute.cs b/src/Api/Utilities/ModelStateValidationFilterAttribute.cs new file mode 100644 index 000000000..13091229d --- /dev/null +++ b/src/Api/Utilities/ModelStateValidationFilterAttribute.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Filters; +using Bit.Api.Models.Response; +using System.Linq; + +namespace Bit.Api.Utilities +{ + public class ModelStateValidationFilterAttribute : ActionFilterAttribute + { + public override void OnActionExecuting(ActionExecutingContext context) + { + var model = context.ActionArguments.FirstOrDefault(a => a.Key == "model"); + if(model.Key == "model" && model.Value == null) + { + context.ModelState.AddModelError(string.Empty, "Body is empty."); + } + + if(!context.ModelState.IsValid) + { + context.Result = new BadRequestObjectResult(new ErrorResponseModel(context.ModelState)); + } + } + } +} diff --git a/src/Api/project.json b/src/Api/project.json new file mode 100644 index 000000000..4c4fc7d18 --- /dev/null +++ b/src/Api/project.json @@ -0,0 +1,42 @@ +{ + "userSecretsId": "aspnet5-bitwarden-Api", + "version": "0.0.1-*", + "compilationOptions": { + "emitEntryPoint": true + }, + + "dependencies": { + "Core": { + "version": "0.0.1", + "target": "project" + }, + "Microsoft.AspNet.DataProtection.Extensions": "1.0.0-rc1-final", + "Microsoft.AspNet.IISPlatformHandler": "1.0.0-rc1-final", + "Microsoft.AspNet.Mvc": "6.0.0-rc1-final", + "Microsoft.AspNet.Server.Kestrel": "1.0.0-rc1-final", + "Microsoft.AspNet.StaticFiles": "1.0.0-rc1-final", + "Microsoft.Extensions.Configuration.UserSecrets": "1.0.0-rc1-final", + "Microsoft.Extensions.Logging": "1.0.0-rc1-final", + "Microsoft.Extensions.Logging.Console": "1.0.0-rc1-final", + "Microsoft.Extensions.Logging.Debug": "1.0.0-rc1-final", + "Microsoft.AspNet.Cors": "6.0.0-rc1-final", + "Microsoft.AspNet.Diagnostics": "1.0.0-rc1-final" + }, + + "commands": { + "web": "Microsoft.AspNet.Server.Kestrel" + }, + + "frameworks": { + "dnx451": { } + }, + + "exclude": [ + "wwwroot", + "node_modules" + ], + "publishExclude": [ + "**.user", + "**.vspscc" + ] +} diff --git a/src/Api/settings.Production.json b/src/Api/settings.Production.json new file mode 100644 index 000000000..040bb384e --- /dev/null +++ b/src/Api/settings.Production.json @@ -0,0 +1,5 @@ +{ + "globalSettings": { + "baseVaultUri": "https://vault.bitwarden.com" + } +} diff --git a/src/Api/settings.Staging.json b/src/Api/settings.Staging.json new file mode 100644 index 000000000..040bb384e --- /dev/null +++ b/src/Api/settings.Staging.json @@ -0,0 +1,5 @@ +{ + "globalSettings": { + "baseVaultUri": "https://vault.bitwarden.com" + } +} diff --git a/src/Api/settings.json b/src/Api/settings.json new file mode 100644 index 000000000..f5e067e59 --- /dev/null +++ b/src/Api/settings.json @@ -0,0 +1,17 @@ +{ + "globalSettings": { + "siteName": "bitwarden", + "baseVaultUri": "http://localhost:4001", + "documentDB": { + "uri": "SECRET", + "key": "SECRET", + "databaseId": "SECRET", + "collectionIdPrefix": "SECRET", + "numberOfCollections": 1 + }, + "mail": { + "apiKey": "SECRET", + "replyToEmail": "do-not-reply@bitwarden.com" + } + } +} diff --git a/src/Api/wwwroot/web.config b/src/Api/wwwroot/web.config new file mode 100644 index 000000000..e780518bc --- /dev/null +++ b/src/Api/wwwroot/web.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Core/Core.xproj b/src/Core/Core.xproj new file mode 100644 index 000000000..f5180cb36 --- /dev/null +++ b/src/Core/Core.xproj @@ -0,0 +1,18 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 3973d21b-a692-4b60-9b70-3631c057423a + Bit.Core + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + + + \ No newline at end of file diff --git a/src/Core/CurrentContext.cs b/src/Core/CurrentContext.cs new file mode 100644 index 000000000..e9da8ebdb --- /dev/null +++ b/src/Core/CurrentContext.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bit.Core.Domains; + +namespace Bit.Core +{ + public class CurrentContext + { + public virtual User User { get; set; } + } +} diff --git a/src/Core/Domains/Cipher.cs b/src/Core/Domains/Cipher.cs new file mode 100644 index 000000000..87acabb06 --- /dev/null +++ b/src/Core/Domains/Cipher.cs @@ -0,0 +1,23 @@ +using System; +using Newtonsoft.Json; +using Bit.Core.Enums; + +namespace Bit.Core.Domains +{ + public abstract class Cipher : IDataObject + { + internal static string TypeValue = "cipher"; + + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("type")] + public string Type { get; private set; } = TypeValue; + public abstract CipherType CipherType { get; protected set; } + + public string UserId { get; set; } + public string Name { get; set; } + public bool Dirty { get; set; } + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + } +} diff --git a/src/Core/Domains/Folder.cs b/src/Core/Domains/Folder.cs new file mode 100644 index 000000000..1474a7bc2 --- /dev/null +++ b/src/Core/Domains/Folder.cs @@ -0,0 +1,9 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Domains +{ + public class Folder : Cipher, IDataObject + { + public override CipherType CipherType { get; protected set; } = CipherType.Folder; + } +} diff --git a/src/Core/Domains/Role.cs b/src/Core/Domains/Role.cs new file mode 100644 index 000000000..cb9a3b8df --- /dev/null +++ b/src/Core/Domains/Role.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Domains +{ + /// + /// This class is not used. It is implemented to make the Identity provider happy. + /// + public class Role + { + public string Name { get; set; } + } +} diff --git a/src/Core/Domains/Site.cs b/src/Core/Domains/Site.cs new file mode 100644 index 000000000..c227e6bb2 --- /dev/null +++ b/src/Core/Domains/Site.cs @@ -0,0 +1,16 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Domains +{ + public class Site : Cipher, IDataObject + { + public override CipherType CipherType { get; protected set; } = CipherType.Site; + + public string FolderId { get; set; } + + public string Uri { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string Notes { get; set; } + } +} diff --git a/src/Core/Domains/User.cs b/src/Core/Domains/User.cs new file mode 100644 index 000000000..fdee4da12 --- /dev/null +++ b/src/Core/Domains/User.cs @@ -0,0 +1,30 @@ +using System; +using Newtonsoft.Json; +using Bit.Core.Enums; + +namespace Bit.Core.Domains +{ + public class User : IDataObject + { + internal static string TypeValue = "user"; + + [JsonProperty("id")] + public string Id { get; set; } = Guid.NewGuid().ToString(); + [JsonProperty("type")] + public string Type { get; private set; } = TypeValue; + + public string Name { get; set; } + public string Email { get; set; } + public string MasterPassword { get; set; } + public string MasterPasswordHint { get; set; } + public string Culture { get; set; } + public string SecurityStamp { get; set; } + public string OldEmail { get; set; } + public string OldMasterPassword { get; set; } + public bool TwoFactorEnabled { get; set; } + public TwoFactorProvider? TwoFactorProvider { get; set; } + public string AuthenticatorKey { get; set; } + + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + } +} diff --git a/src/Core/Enums/CipherType.cs b/src/Core/Enums/CipherType.cs new file mode 100644 index 000000000..6e9bb3d8c --- /dev/null +++ b/src/Core/Enums/CipherType.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Enums +{ + public enum CipherType + { + Folder = 0, + Site = 1 + } +} diff --git a/src/Core/Enums/TwoFactorProvider.cs b/src/Core/Enums/TwoFactorProvider.cs new file mode 100644 index 000000000..2b9eb0e86 --- /dev/null +++ b/src/Core/Enums/TwoFactorProvider.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Enums +{ + public enum TwoFactorProvider + { + Authenticator = 0 + } +} diff --git a/src/Core/Exceptions/BadRequestException.cs b/src/Core/Exceptions/BadRequestException.cs new file mode 100644 index 000000000..260e0723c --- /dev/null +++ b/src/Core/Exceptions/BadRequestException.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Bit.Core.Exceptions +{ + public class BadRequestException : Exception + { + public BadRequestException(string message) : this(string.Empty, message) { } + + public BadRequestException(string key, string errorMessage) + : base("The model state is invalid.") + { + ModelState = new ModelStateDictionary(); + ModelState.AddModelError(key, errorMessage); + } + + public BadRequestException(ModelStateDictionary modelState) + : base("The model state is invalid.") + { + if(modelState.IsValid || modelState.ErrorCount == 0) + { + return; + } + + ModelState = modelState; + } + + public ModelStateDictionary ModelState { get; set; } + } +} diff --git a/src/Core/Exceptions/NotFoundException.cs b/src/Core/Exceptions/NotFoundException.cs new file mode 100644 index 000000000..75d43a2e4 --- /dev/null +++ b/src/Core/Exceptions/NotFoundException.cs @@ -0,0 +1,6 @@ +using System; + +namespace Bit.Core.Exceptions +{ + public class NotFoundException : Exception { } +} diff --git a/src/Core/GlobalSettings.cs b/src/Core/GlobalSettings.cs new file mode 100644 index 000000000..b62bdc270 --- /dev/null +++ b/src/Core/GlobalSettings.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +namespace Bit.Core +{ + public class GlobalSettings + { + public string SiteName { get; set; } + public string BaseVaultUri { get; set; } + public virtual DocumentDBSettings DocumentDB { get; set; } = new DocumentDBSettings(); + public virtual MailSettings Mail { get; set; } = new MailSettings(); + + public class DocumentDBSettings + { + public string Uri { get; set; } + public string Key { get; set; } + public string DatabaseId { get; set; } + public string CollectionIdPrefix { get; set; } + public int NumberOfCollections { get; set; } + } + + public class MailSettings + { + public string APIKey { get; set; } + public string ReplyToEmail { get; set; } + } + } +} diff --git a/src/Core/IDataObject.cs b/src/Core/IDataObject.cs new file mode 100644 index 000000000..2ec0c6bb9 --- /dev/null +++ b/src/Core/IDataObject.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Bit.Core +{ + public interface IDataObject + { + [JsonProperty("id")] + string Id { get; set; } + [JsonProperty("type")] + string Type { get; } + } +} diff --git a/src/Core/Identity/AuthenticatorTokenProvider.cs b/src/Core/Identity/AuthenticatorTokenProvider.cs new file mode 100644 index 000000000..f259f784a --- /dev/null +++ b/src/Core/Identity/AuthenticatorTokenProvider.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Base32; +using Microsoft.AspNet.Identity; +using Bit.Core.Domains; +using Bit.Core.Enums; +using OtpSharp; + +namespace Bit.Core.Identity +{ + public class AuthenticatorTokenProvider : IUserTokenProvider + { + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + { + var canGenerate = user.TwoFactorEnabled + && user.TwoFactorProvider.HasValue + && user.TwoFactorProvider.Value == TwoFactorProvider.Authenticator + && !string.IsNullOrWhiteSpace(user.AuthenticatorKey); + + return Task.FromResult(canGenerate); + } + + public Task GetUserModifierAsync(string purpose, UserManager manager, User user) + { + return Task.FromResult(null); + } + + public Task GenerateAsync(string purpose, UserManager manager, User user) + { + return Task.FromResult(null); + } + + public Task ValidateAsync(string purpose, string token, UserManager manager, User user) + { + var otp = new Totp(Base32Encoder.Decode(user.AuthenticatorKey)); + + long timeStepMatched; + var valid = otp.VerifyTotp(token, out timeStepMatched, new VerificationWindow(2, 2)); + + return Task.FromResult(valid); + } + } +} diff --git a/src/Core/Identity/JwtBearerBuilderExtensions.cs b/src/Core/Identity/JwtBearerBuilderExtensions.cs new file mode 100644 index 000000000..bd66ce15c --- /dev/null +++ b/src/Core/Identity/JwtBearerBuilderExtensions.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.OptionsModel; +using Bit.Core.Domains; +using Microsoft.AspNet.Authentication.JwtBearer; + +namespace Bit.Core.Identity +{ + public static class JwtBearerBuilderExtensions + { + public static IApplicationBuilder UseJwtBearerIdentity(this IApplicationBuilder app) + { + if(app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + var marker = app.ApplicationServices.GetService(); + if(marker == null) + { + throw new InvalidOperationException("Must Call AddJwtBearerIdentity"); + } + + var jwtOptions = app.ApplicationServices.GetRequiredService>().Value; + var jwtSignInManager = app.ApplicationServices.GetRequiredService(); + app.UseJwtBearerAuthentication(options => + { + // Basic settings - signing key to validate with, audience and issuer. + //options.TokenValidationParameters.IssuerSigningKey = key; + options.TokenValidationParameters.ValidAudience = jwtOptions.Audience; + options.TokenValidationParameters.ValidIssuer = jwtOptions.Issuer; + + options.TokenValidationParameters.RequireExpirationTime = true; + options.TokenValidationParameters.RequireSignedTokens = false; + + // When receiving a token, check that we've signed it. + options.TokenValidationParameters.ValidateSignature = false; + + //// When receiving a token, check that it is still valid. + options.TokenValidationParameters.ValidateLifetime = true; + + // This defines the maximum allowable clock skew - i.e. provides a tolerance on the token expiry time + // when validating the lifetime. As we're creating the tokens locally and validating them on the same + // machines which should have synchronised time, this can be set to zero. Where external tokens are + // used, some leeway here could be useful. + options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(0); + + options.Events = new JwtBearerEvents + { + OnValidatedToken = JwtBearerEventImplementations.ValidatedTokenAsync, + OnAuthenticationFailed = JwtBearerEventImplementations.AuthenticationFailedAsync + }; + }); + + return app; + } + } +} diff --git a/src/Core/Identity/JwtBearerEventImplementations.cs b/src/Core/Identity/JwtBearerEventImplementations.cs new file mode 100644 index 000000000..124d1a8ed --- /dev/null +++ b/src/Core/Identity/JwtBearerEventImplementations.cs @@ -0,0 +1,50 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNet.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using System.IdentityModel.Tokens; +using Bit.Core.Repositories; +using Microsoft.AspNet.Authentication; +using Microsoft.AspNet.Http.Authentication; + +namespace Bit.Core.Identity +{ + public static class JwtBearerEventImplementations + { + public async static Task ValidatedTokenAsync(ValidatedTokenContext context) + { + if(context.HttpContext.RequestServices == null) + { + throw new InvalidOperationException("RequestServices is null"); + } + + var userRepository = context.HttpContext.RequestServices.GetRequiredService(); + var manager = context.HttpContext.RequestServices.GetRequiredService(); + + var userId = context.AuthenticationTicket.Principal.GetUserId(); + var user = await userRepository.GetByIdAsync(userId); + + // validate security token + if(!await manager.ValidateSecurityStampAsync(user, context.AuthenticationTicket.Principal)) + { + throw new SecurityTokenValidationException("Bad security stamp."); + } + + // register the current context user + var currentContext = context.HttpContext.RequestServices.GetRequiredService(); + currentContext.User = user; + } + + public static Task AuthenticationFailedAsync(AuthenticationFailedContext context) + { + if(!context.HttpContext.User.Identity.IsAuthenticated) + { + context.State = EventResultState.HandledResponse; + context.AuthenticationTicket = new AuthenticationTicket(context.HttpContext.User, new AuthenticationProperties(), context.Options.AuthenticationScheme); + } + + return Task.FromResult(null); + } + } +} diff --git a/src/Core/Identity/JwtBearerIdentityOptions.cs b/src/Core/Identity/JwtBearerIdentityOptions.cs new file mode 100644 index 000000000..c8d8ac523 --- /dev/null +++ b/src/Core/Identity/JwtBearerIdentityOptions.cs @@ -0,0 +1,16 @@ +using System; +using System.IdentityModel.Tokens; + +namespace Bit.Core.Identity +{ + public class JwtBearerIdentityOptions + { + public string Audience { get; set; } + public string Issuer { get; set; } + public SigningCredentials SigningCredentials { get; set; } + public TimeSpan? TokenLifetime { get; set; } + public TimeSpan? TwoFactorTokenLifetime { get; set; } + public string AuthenticationMethod { get; set; } = "Application"; + public string TwoFactorAuthenticationMethod { get; set; } = "TwoFactor"; + } +} diff --git a/src/Core/Identity/JwtBearerIdentityServiceCollectionExtensions.cs b/src/Core/Identity/JwtBearerIdentityServiceCollectionExtensions.cs new file mode 100644 index 000000000..c6200c9c8 --- /dev/null +++ b/src/Core/Identity/JwtBearerIdentityServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.AspNet.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Bit.Core.Domains; + +namespace Bit.Core.Identity +{ + public static class JwtBearerIdentityServiceCollectionExtensions + { + public static IdentityBuilder AddJwtBearerIdentit( + this IServiceCollection services) + { + return services.AddJwtBearerIdentity(setupAction: null, jwtBearerSetupAction: null); + } + + public static IdentityBuilder AddJwtBearerIdentity( + this IServiceCollection services, + Action setupAction, + Action jwtBearerSetupAction) + { + // Services used by identity + services.AddOptions(); + services.AddAuthentication(); + + // Identity services + services.TryAddSingleton(); + services.TryAddScoped, UserValidator>(); + services.TryAddScoped, PasswordValidator>(); + services.TryAddScoped, PasswordHasher>(); + services.TryAddScoped(); + services.TryAddScoped, RoleValidator>(); + // No interface for the error describer so we can add errors without rev'ing the interface + services.TryAddScoped(); + services.TryAddScoped>(); + services.TryAddScoped, UserClaimsPrincipalFactory>(); + services.TryAddScoped, UserManager>(); + services.TryAddScoped(); + services.TryAddScoped, RoleManager>(); + + if(setupAction != null) + { + services.Configure(setupAction); + } + + if(jwtBearerSetupAction != null) + { + services.Configure(jwtBearerSetupAction); + } + + return new IdentityBuilder(typeof(User), typeof(Role), services); + } + } +} diff --git a/src/Core/Identity/JwtBearerSignInManager.cs b/src/Core/Identity/JwtBearerSignInManager.cs new file mode 100644 index 000000000..1edc46a2e --- /dev/null +++ b/src/Core/Identity/JwtBearerSignInManager.cs @@ -0,0 +1,160 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNet.Authentication.JwtBearer; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.OptionsModel; +using Bit.Core.Domains; + +namespace Bit.Core.Identity +{ + public class JwtBearerSignInManager + { + public JwtBearerSignInManager( + UserManager userManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + IOptions jwtIdentityOptionsAccessor, + IOptions jwtOptionsAccessor, + ILogger logger) + { + UserManager = userManager; + Context = contextAccessor.HttpContext; + ClaimsFactory = claimsFactory; + IdentityOptions = optionsAccessor?.Value ?? new IdentityOptions(); + JwtIdentityOptions = jwtIdentityOptionsAccessor?.Value ?? new JwtBearerIdentityOptions(); + JwtBearerOptions = jwtOptionsAccessor?.Value ?? new JwtBearerOptions(); + } + + internal UserManager UserManager { get; set; } + internal HttpContext Context { get; set; } + internal IUserClaimsPrincipalFactory ClaimsFactory { get; set; } + internal IdentityOptions IdentityOptions { get; set; } + internal JwtBearerIdentityOptions JwtIdentityOptions { get; set; } + internal JwtBearerOptions JwtBearerOptions { get; set; } + + public async Task CreateUserPrincipalAsync(User user) => await ClaimsFactory.CreateAsync(user); + + public Task ValidateSecurityStampAsync(User user, ClaimsPrincipal principal) + { + if(user != null && UserManager.SupportsUserSecurityStamp) + { + var securityStamp = principal.FindFirstValue(IdentityOptions.ClaimsIdentity.SecurityStampClaimType); + if(securityStamp == user.SecurityStamp) + { + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + public async Task PasswordSignInAsync(User user, string password) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if(await UserManager.CheckPasswordAsync(user, password)) + { + return await SignInOrTwoFactorAsync(user); + } + + return JwtBearerSignInResult.Failed; + } + + public async Task PasswordSignInAsync(string userName, string password) + { + var user = await UserManager.FindByNameAsync(userName); + if(user == null) + { + return JwtBearerSignInResult.Failed; + } + + return await PasswordSignInAsync(user, password); + } + + public async Task TwoFactorSignInAsync(User user, string provider, string code) + { + if(user == null) + { + return JwtBearerSignInResult.Failed; + } + + if(await UserManager.VerifyTwoFactorTokenAsync(user, provider, code)) + { + var token = await SignInAsync(user, false); + + var success = JwtBearerSignInResult.Success; + success.Token = token; + success.User = user; + + return success; + } + + return JwtBearerSignInResult.Failed; + } + + private async Task SignInAsync(User user, bool twoFactor) + { + var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); + + DateTime? tokenExpiration = null; + var userPrincipal = await CreateUserPrincipalAsync(user); + if(twoFactor) + { + userPrincipal.Identities.First().AddClaim(new Claim(ClaimTypes.AuthenticationMethod, JwtIdentityOptions.TwoFactorAuthenticationMethod)); + if(JwtIdentityOptions.TwoFactorTokenLifetime.HasValue) + { + tokenExpiration = DateTime.UtcNow.Add(JwtIdentityOptions.TwoFactorTokenLifetime.Value); + } + } + else + { + userPrincipal.Identities.First().AddClaim(new Claim(ClaimTypes.AuthenticationMethod, JwtIdentityOptions.AuthenticationMethod)); + if(JwtIdentityOptions.TokenLifetime.HasValue) + { + tokenExpiration = DateTime.UtcNow.Add(JwtIdentityOptions.TokenLifetime.Value); + } + } + + var securityToken = handler.CreateToken( + issuer: JwtIdentityOptions.Issuer, + audience: JwtIdentityOptions.Audience, + signingCredentials: JwtIdentityOptions.SigningCredentials, + subject: userPrincipal.Identities.First(), + expires: tokenExpiration); + + return handler.WriteToken(securityToken); + } + + private async Task SignInOrTwoFactorAsync(User user) + { + if(UserManager.SupportsUserTwoFactor && + await UserManager.GetTwoFactorEnabledAsync(user) && + (await UserManager.GetValidTwoFactorProvidersAsync(user)).Count > 0) + { + var twoFactorToken = await SignInAsync(user, true); + + var twoFactorResult = JwtBearerSignInResult.TwoFactorRequired; + twoFactorResult.Token = twoFactorToken; + twoFactorResult.User = user; + + return twoFactorResult; + } + + var token = await SignInAsync(user, false); + + var result = JwtBearerSignInResult.Success; + result.Token = token; + result.User = user; + + return result; + } + } +} diff --git a/src/Core/Identity/JwtBearerSignInResult.cs b/src/Core/Identity/JwtBearerSignInResult.cs new file mode 100644 index 000000000..2bccc59e3 --- /dev/null +++ b/src/Core/Identity/JwtBearerSignInResult.cs @@ -0,0 +1,34 @@ +using Bit.Core.Domains; + +namespace Bit.Core.Identity +{ + public class JwtBearerSignInResult + { + private static readonly JwtBearerSignInResult _success = new JwtBearerSignInResult { Succeeded = true }; + private static readonly JwtBearerSignInResult _failed = new JwtBearerSignInResult(); + private static readonly JwtBearerSignInResult _lockedOut = new JwtBearerSignInResult { IsLockedOut = true }; + private static readonly JwtBearerSignInResult _notAllowed = new JwtBearerSignInResult { IsNotAllowed = true }; + private static readonly JwtBearerSignInResult _twoFactorRequired = new JwtBearerSignInResult { RequiresTwoFactor = true }; + + public bool Succeeded { get; protected set; } + public bool IsLockedOut { get; protected set; } + public bool IsNotAllowed { get; protected set; } + public bool RequiresTwoFactor { get; protected set; } + public string Token { get; set; } + public User User { get; set; } + + public static JwtBearerSignInResult Success => _success; + public static JwtBearerSignInResult Failed => _failed; + public static JwtBearerSignInResult LockedOut => _lockedOut; + public static JwtBearerSignInResult NotAllowed => _notAllowed; + public static JwtBearerSignInResult TwoFactorRequired => _twoFactorRequired; + + public override string ToString() + { + return IsLockedOut ? "Lockedout" : + IsNotAllowed ? "NotAllowed" : + RequiresTwoFactor ? "RequiresTwoFactor" : + Succeeded ? "Succeeded" : "Failed"; + } + } +} diff --git a/src/Core/Identity/LowerInvariantLookupNormalizer.cs b/src/Core/Identity/LowerInvariantLookupNormalizer.cs new file mode 100644 index 000000000..3abacaba7 --- /dev/null +++ b/src/Core/Identity/LowerInvariantLookupNormalizer.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNet.Identity; + +namespace Bit.Core.Identity +{ + public class LowerInvariantLookupNormalizer : ILookupNormalizer + { + public string Normalize(string key) + { + return key?.Normalize().ToLowerInvariant(); + } + } +} diff --git a/src/Core/Identity/RoleStore.cs b/src/Core/Identity/RoleStore.cs new file mode 100644 index 000000000..4a4a125e4 --- /dev/null +++ b/src/Core/Identity/RoleStore.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; +using Bit.Core.Domains; + +namespace Bit.Core.Identity +{ + public class RoleStore : IRoleStore + { + public void Dispose() { } + + public Task CreateAsync(Role role, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(Role role, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task FindByIdAsync(string roleId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetNormalizedRoleNameAsync(Role role, CancellationToken cancellationToken) + { + return Task.FromResult(role.Name); + } + + public Task GetRoleIdAsync(Role role, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetRoleNameAsync(Role role, CancellationToken cancellationToken) + { + return Task.FromResult(role.Name); + } + + public Task SetNormalizedRoleNameAsync(Role role, string normalizedName, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task SetRoleNameAsync(Role role, string roleName, CancellationToken cancellationToken) + { + role.Name = roleName; + return Task.FromResult(null); + } + + public Task UpdateAsync(Role role, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Core/Identity/UserStore.cs b/src/Core/Identity/UserStore.cs new file mode 100644 index 000000000..8e265dfd9 --- /dev/null +++ b/src/Core/Identity/UserStore.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; +using Bit.Core.Domains; +using Bit.Core.Repositories; + +namespace Bit.Core.Identity +{ + public class UserStore : + IUserStore, + IUserPasswordStore, + IUserEmailStore, + IUserTwoFactorStore, + IUserSecurityStampStore + { + private readonly IUserRepository _userRepository; + + public UserStore(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public void Dispose() { } + + public async Task CreateAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + await _userRepository.CreateAsync(user); + return IdentityResult.Success; + } + + public async Task DeleteAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + await _userRepository.DeleteAsync(user); + return IdentityResult.Success; + } + + public async Task FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken)) + { + return await _userRepository.GetByEmailAsync(normalizedEmail); + } + + public async Task FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + return await _userRepository.GetByIdAsync(userId); + } + + public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken)) + { + return await _userRepository.GetByEmailAsync(normalizedUserName); + } + + public Task GetEmailAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(user.Email); + } + + public Task GetEmailConfirmedAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(true); // all emails are confirmed + } + + public Task GetNormalizedEmailAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(user.Email); + } + + public Task GetNormalizedUserNameAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(user.Email); + } + + public Task GetPasswordHashAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(user.MasterPassword); + } + + public Task GetUserIdAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(user.Id); + } + + public Task GetUserNameAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(user.Email); + } + + public Task HasPasswordAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(!string.IsNullOrWhiteSpace(user.MasterPassword)); + } + + public Task SetEmailAsync(User user, string email, CancellationToken cancellationToken = default(CancellationToken)) + { + user.Email = email; + return Task.FromResult(null); + } + + public Task SetEmailConfirmedAsync(User user, bool confirmed, CancellationToken cancellationToken = default(CancellationToken)) + { + // do nothing + return Task.FromResult(null); + } + + public Task SetNormalizedEmailAsync(User user, string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken)) + { + user.Email = normalizedEmail; + return Task.FromResult(null); + } + + public Task SetNormalizedUserNameAsync(User user, string normalizedName, CancellationToken cancellationToken = default(CancellationToken)) + { + user.Email = normalizedName; + return Task.FromResult(null); + } + + public Task SetPasswordHashAsync(User user, string passwordHash, CancellationToken cancellationToken = default(CancellationToken)) + { + user.MasterPassword = passwordHash; + return Task.FromResult(null); + } + + public Task SetUserNameAsync(User user, string userName, CancellationToken cancellationToken = default(CancellationToken)) + { + user.Email = userName; + return Task.FromResult(null); + } + + public async Task UpdateAsync(User user, CancellationToken cancellationToken = default(CancellationToken)) + { + await _userRepository.ReplaceAsync(user); + return IdentityResult.Success; + } + + public Task SetTwoFactorEnabledAsync(User user, bool enabled, CancellationToken cancellationToken) + { + user.TwoFactorEnabled = enabled; + return Task.FromResult(null); + } + + public Task GetTwoFactorEnabledAsync(User user, CancellationToken cancellationToken) + { + return Task.FromResult(user.TwoFactorEnabled && user.TwoFactorProvider.HasValue); + } + + public Task SetSecurityStampAsync(User user, string stamp, CancellationToken cancellationToken) + { + user.SecurityStamp = stamp; + return Task.FromResult(null); + } + + public Task GetSecurityStampAsync(User user, CancellationToken cancellationToken) + { + return Task.FromResult(user.SecurityStamp); + } + } +} diff --git a/src/Core/Properties/AssemblyInfo.cs b/src/Core/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..3508afc0d --- /dev/null +++ b/src/Core/Properties/AssemblyInfo.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Bit.Core")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("bitwarden")] +[assembly: AssemblyProduct("bitwarden")] +[assembly: AssemblyCopyright("Copyright © 2015")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("3973d21b-a692-4b60-9b70-3631c057423a")] diff --git a/src/Core/Repositories/DocumentDB/BaseRepository.cs b/src/Core/Repositories/DocumentDB/BaseRepository.cs new file mode 100644 index 000000000..794c0c252 --- /dev/null +++ b/src/Core/Repositories/DocumentDB/BaseRepository.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.Azure.Documents.Client; + +namespace Bit.Core.Repositories.DocumentDB +{ + public abstract class BaseRepository where T : IDataObject + { + public BaseRepository(DocumentClient client, string databaseId, string documentType = null) + { + Client = client; + DatabaseId = databaseId; + DatabaseUri = UriFactory.CreateDatabaseUri(databaseId); + PartitionResolver = client.PartitionResolvers[DatabaseUri.OriginalString]; + + if(string.IsNullOrWhiteSpace(documentType)) + { + DocumentType = typeof(T).Name.ToLower(); + } + else + { + DocumentType = documentType; + } + } + + protected DocumentClient Client { get; private set; } + protected string DatabaseId { get; private set; } + protected Uri DatabaseUri { get; private set; } + protected IPartitionResolver PartitionResolver { get; private set; } + protected string DocumentType { get; private set; } + + protected string ResolveSprocIdLink(T obj, string sprocId) + { + return string.Format("{0}/sprocs/{1}", ResolveCollectionIdLink(obj), sprocId); + } + + protected string ResolveSprocIdLink(string partitionKey, string sprocId) + { + return string.Format("{0}/sprocs/{1}", ResolveCollectionIdLink(partitionKey), sprocId); + } + + protected string ResolveDocumentIdLink(T obj) + { + return string.Format("{0}/docs/{1}", ResolveCollectionIdLink(obj), obj.Id); + } + + protected string ResolveDocumentIdLink(string id) + { + return ResolveDocumentIdLink(id, id); + } + + protected string ResolveDocumentIdLink(string partitionKey, string id) + { + return string.Format("{0}/docs/{1}", ResolveCollectionIdLink(partitionKey), id); + } + + protected string ResolveCollectionIdLink(T obj) + { + var partitionKey = PartitionResolver.GetPartitionKey(obj); + return ResolveCollectionIdLink(partitionKey); + } + + protected string ResolveCollectionIdLink(object partitionKey) + { + return PartitionResolver.ResolveForCreate(partitionKey); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/CipherRepository.cs b/src/Core/Repositories/DocumentDB/CipherRepository.cs new file mode 100644 index 000000000..698e8c899 --- /dev/null +++ b/src/Core/Repositories/DocumentDB/CipherRepository.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Documents.Client; +using Bit.Core.Domains; + +namespace Bit.Core.Repositories.DocumentDB +{ + public class CipherRepository : BaseRepository, ICipherRepository + { + public CipherRepository(DocumentClient client, string databaseId, string documentType = null) + : base(client, databaseId, documentType) + { } + + public async Task UpdateDirtyCiphersAsync(IEnumerable ciphers) + { + // Make sure we are dealing with cipher types since we accept any via dynamic. + var cleanedCiphers = ciphers.Where(c => c is Cipher); + if(cleanedCiphers.Count() == 0) + { + return; + } + + var userId = ((Cipher)cleanedCiphers.First()).UserId; + StoredProcedureResponse sprocResponse = await Client.ExecuteStoredProcedureAsync( + ResolveSprocIdLink(userId, "bulkUpdateDirtyCiphers"), + // Do sets of 50. Recursion will handle the rest below. + cleanedCiphers.Take(50), + userId, + Cipher.TypeValue); + + var replacedCount = sprocResponse.Response; + if(replacedCount != cleanedCiphers.Count()) + { + await UpdateDirtyCiphersAsync(cleanedCiphers.Skip(replacedCount)); + } + } + } +} diff --git a/src/Core/Repositories/DocumentDB/FolderRepository.cs b/src/Core/Repositories/DocumentDB/FolderRepository.cs new file mode 100644 index 000000000..eb21bed00 --- /dev/null +++ b/src/Core/Repositories/DocumentDB/FolderRepository.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Documents.Client; +using Bit.Core.Domains; +using Bit.Core.Enums; + +namespace Bit.Core.Repositories.DocumentDB +{ + public class FolderRepository : Repository, IFolderRepository + { + public FolderRepository(DocumentClient client, string databaseId) + : base(client, databaseId) + { } + + public async Task GetByIdAsync(string id, string userId) + { + var doc = await Client.ReadDocumentAsync(ResolveDocumentIdLink(userId, id)); + if(doc?.Resource == null) + { + return null; + } + + var folder = (Folder)((dynamic)doc.Resource); + if(folder.UserId != userId) + { + return null; + } + + return folder; + } + + public Task> GetManyByUserIdAsync(string userId) + { + var docs = Client.CreateDocumentQuery(DatabaseUri, null, userId) + .Where(d => d.Type == Cipher.TypeValue && d.CipherType == CipherType.Folder && d.UserId == userId).AsEnumerable(); + + return Task.FromResult>(docs.ToList()); + } + + public Task> GetManyByUserIdAsync(string userId, bool dirty) + { + var docs = Client.CreateDocumentQuery(DatabaseUri, null, userId) + .Where(d => d.Type == Cipher.TypeValue && d.CipherType == CipherType.Folder && d.UserId == userId && d.Dirty == dirty).AsEnumerable(); + + return Task.FromResult>(docs.ToList()); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/Repository.cs b/src/Core/Repositories/DocumentDB/Repository.cs new file mode 100644 index 000000000..9b54cf164 --- /dev/null +++ b/src/Core/Repositories/DocumentDB/Repository.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Documents.Client; + +namespace Bit.Core.Repositories.DocumentDB +{ + public abstract class Repository : BaseRepository, IRepository where T : IDataObject + { + public Repository(DocumentClient client, string databaseId, string documentType = null) + : base(client, databaseId, documentType) + { } + + public virtual Task GetByIdAsync(string id) + { + // NOTE: Not an ideal condition, scanning all collections. + // Override this method if you can implement a direct partition lookup based on the id. + // Use the inherited GetByPartitionIdAsync method to implement your override. + var docs = Client.CreateDocumentQuery(DatabaseUri, new FeedOptions { MaxItemCount = 1 }) + .Where(d => d.Id == id).AsEnumerable(); + + return Task.FromResult(docs.FirstOrDefault()); + } + + public virtual async Task CreateAsync(T obj) + { + var result = await Client.CreateDocumentAsync(DatabaseUri, obj); + obj.Id = result.Resource.Id; + } + + public virtual async Task ReplaceAsync(T obj) + { + await Client.ReplaceDocumentAsync(ResolveDocumentIdLink(obj), obj); + } + + public virtual async Task UpsertAsync(T obj) + { + await Client.UpsertDocumentAsync(ResolveDocumentIdLink(obj), obj); + } + + public virtual async Task DeleteAsync(T obj) + { + await Client.DeleteDocumentAsync(ResolveDocumentIdLink(obj)); + } + + public virtual async Task DeleteByIdAsync(string id) + { + // NOTE: Not an ideal condition, scanning all collections. + // Override this method if you can implement a direct partition lookup based on the id. + // Use the inherited DeleteByPartitionIdAsync method to implement your override. + var docs = Client.CreateDocumentQuery(DatabaseUri, new FeedOptions { MaxItemCount = 1 }) + .Where(d => d.Id == id).AsEnumerable(); + + if(docs.Count() > 0) + { + await Client.DeleteDocumentAsync(docs.First().SelfLink); + } + } + + protected async Task GetByPartitionIdAsync(string id) + { + var doc = await Client.ReadDocumentAsync(ResolveDocumentIdLink(id)); + if(doc?.Resource == null) + { + return default(T); + } + + return (T)((dynamic)doc.Resource); + } + + protected async Task DeleteByPartitionIdAsync(string id) + { + await Client.DeleteDocumentAsync(ResolveDocumentIdLink(id)); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/SiteRepository.cs b/src/Core/Repositories/DocumentDB/SiteRepository.cs new file mode 100644 index 000000000..5b5dc21df --- /dev/null +++ b/src/Core/Repositories/DocumentDB/SiteRepository.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Documents.Client; +using Bit.Core.Domains; +using Bit.Core.Enums; + +namespace Bit.Core.Repositories.DocumentDB +{ + public class SiteRepository : Repository, ISiteRepository + { + public SiteRepository(DocumentClient client, string databaseId) + : base(client, databaseId) + { } + + public async Task GetByIdAsync(string id, string userId) + { + var doc = await Client.ReadDocumentAsync(ResolveDocumentIdLink(userId, id)); + if(doc?.Resource == null) + { + return null; + } + + var site = (Site)((dynamic)doc.Resource); + if(site.UserId != userId) + { + return null; + } + + return site; + } + + public Task> GetManyByUserIdAsync(string userId) + { + var docs = Client.CreateDocumentQuery(DatabaseUri, null, userId) + .Where(d => d.Type == Cipher.TypeValue && d.CipherType == CipherType.Site && d.UserId == userId).AsEnumerable(); + + return Task.FromResult>(docs.ToList()); + } + + public Task> GetManyByUserIdAsync(string userId, bool dirty) + { + var docs = Client.CreateDocumentQuery(DatabaseUri, null, userId) + .Where(d => d.Type == Cipher.TypeValue && d.CipherType == CipherType.Site && d.UserId == userId && d.Dirty == dirty).AsEnumerable(); + + return Task.FromResult>(docs.ToList()); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/Stored Procedures/bulkUpdateDirtyCiphers.js b/src/Core/Repositories/DocumentDB/Stored Procedures/bulkUpdateDirtyCiphers.js new file mode 100644 index 000000000..dd75296e8 --- /dev/null +++ b/src/Core/Repositories/DocumentDB/Stored Procedures/bulkUpdateDirtyCiphers.js @@ -0,0 +1,87 @@ +// Update an array of dirty ciphers for a user. + +function bulkUpdateDirtyCiphers(ciphers, userId) { + var context = getContext(); + var collection = context.getCollection(); + var collectionLink = collection.getSelfLink(); + var response = context.getResponse(); + + var count = 0; + + // Validate input. + if (!ciphers) { + throw new Error("The ciphers array is undefined or null."); + } + + var ciphersLength = ciphers.length; + if (ciphersLength == 0) { + response.setBody(0); + return; + } + + queryAndReplace(ciphers[count]); + + function queryAndReplace(cipher, continuation) { + var query = { + query: "SELECT * FROM root r WHERE r.id = @id AND r.UserId = @userId AND r.type = 'cipher' AND r.Dirty = true", + parameters: [{ name: '@id', value: cipher.id }, { name: '@userId', value: userId }] + }; + + var requestOptions = { continuation: continuation }; + var accepted = collection.queryDocuments(collectionLink, query, requestOptions, function (err, documents, responseOptions) { + if (err) throw err; + + if (documents.length > 0) { + replace(documents[0], cipher); + } + else if (responseOptions.continuation) { + // try again + queryAndReplace(cipher, responseOptions.continuation); + } + else { + // doc not found, skip it + next(); + } + }); + + if (!accepted) { + response.setBody(count); + } + } + + function replace(doc, placementCipher) { + // site + if (doc.CipherType == 1) { + doc.Username = placementCipher.Username; + doc.Password = placementCipher.Password; + doc.Notes = placementCipher.Notes; + doc.Uri = placementCipher.Uri; + } + + doc.Name = placementCipher.Name; + doc.RevisionDate = placementCipher.RevisionDate; + // no longer dirty + doc.Dirty = false; + + var accepted = collection.replaceDocument(doc._self, doc, function (err) { + if (err) throw err; + + next(); + }); + + if (!accepted) { + response.setBody(count); + } + } + + function next() { + count++; + + if (count >= ciphersLength) { + response.setBody(count); + } + else { + queryAndReplace(ciphers[count]); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/Stored Procedures/replaceUserAndDirtyCiphers.js b/src/Core/Repositories/DocumentDB/Stored Procedures/replaceUserAndDirtyCiphers.js new file mode 100644 index 000000000..d95fd017b --- /dev/null +++ b/src/Core/Repositories/DocumentDB/Stored Procedures/replaceUserAndDirtyCiphers.js @@ -0,0 +1,107 @@ +// Replace user document and mark all related ciphers as dirty. + +function replaceUserAndDirtyCiphers(user) { + var context = getContext(); + var collection = context.getCollection(); + var collectionLink = collection.getSelfLink(); + var response = context.getResponse(); + + // Validate input. + if (!user) { + throw new Error('The user is undefined or null.'); + } + + getUser(function (userDoc) { + replaceUser(userDoc, function (replacedDoc) { + queryAndDirtyCiphers(function () { + response.setBody(replacedDoc); + }); + }); + }); + + function getUser(callback, continuation) { + var query = { + query: 'SELECT * FROM root r WHERE r.id = @id', + parameters: [{ name: '@id', value: user.id }] + }; + + var requestOptions = { continuation: continuation }; + var accepted = collection.queryDocuments(collectionLink, query, requestOptions, function (err, documents, responseOptions) { + if (err) throw err; + + if (documents.length > 0) { + callback(documents[0]); + } + else if (responseOptions.continuation) { + getUser(responseOptions.continuation); + } + else { + throw new Error('User not found.'); + } + }); + + if (!accepted) { + throw new Error('The stored procedure timed out.'); + } + } + + function replaceUser(userDoc, callback) { + var accepted = collection.replaceDocument(userDoc._self, user, {}, function (err, replacedDoc) { + if (err) throw err; + + callback(replacedDoc); + }); + + if (!accepted) { + throw new Error('The stored procedure timed out.'); + } + } + + function queryAndDirtyCiphers(callback, continuation) { + var query = { + query: 'SELECT * FROM root r WHERE r.type = @type AND r.UserId = @userId', + parameters: [{ name: '@type', value: 'cipher' }, { name: '@userId', value: user.id }] + }; + + var requestOptions = { continuation: continuation }; + var accepted = collection.queryDocuments(collectionLink, query, requestOptions, function (err, documents, responseOptions) { + if (err) throw err; + + if (documents.length > 0) { + dirtyCiphers(documents, callback); + } + else if (responseOptions.continuation) { + queryAndDirtyCiphers(callback, responseOptions.continuation); + } + else { + callback(); + } + }); + + if (!accepted) { + throw new Error('The stored procedure timed out.'); + } + } + + function dirtyCiphers(documents, callback) { + if (documents.length > 0) { + // dirty the cipher + documents[0].Dirty = true; + + var requestOptions = { etag: documents[0]._etag }; + var accepted = collection.replaceDocument(documents[0]._self, documents[0], requestOptions, function (err) { + if (err) throw err; + + documents.shift(); + dirtyCiphers(documents, callback); + }); + + if (!accepted) { + throw new Error('The stored procedure timed out.'); + } + } + else { + callback(); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/UserRepository.cs b/src/Core/Repositories/DocumentDB/UserRepository.cs new file mode 100644 index 000000000..86c4ade2d --- /dev/null +++ b/src/Core/Repositories/DocumentDB/UserRepository.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Documents.Client; + +namespace Bit.Core.Repositories.DocumentDB +{ + public class UserRepository : Repository, IUserRepository + { + public UserRepository(DocumentClient client, string databaseId) + : base(client, databaseId) + { } + + public override async Task GetByIdAsync(string id) + { + return await GetByPartitionIdAsync(id); + } + + public Task GetByEmailAsync(string email) + { + var docs = Client.CreateDocumentQuery(DatabaseUri, new FeedOptions { MaxItemCount = 1 }) + .Where(d => d.Type == Domains.User.TypeValue && d.Email == email).AsEnumerable(); + + return Task.FromResult(docs.FirstOrDefault()); + } + + public async Task ReplaceAndDirtyCiphersAsync(Domains.User user) + { + await Client.ExecuteStoredProcedureAsync(ResolveSprocIdLink(user, "replaceUserAndDirtyCiphers"), user); + } + + public override async Task DeleteByIdAsync(string id) + { + await DeleteByPartitionIdAsync(id); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/Utilities/DocumentClientHelpers.cs b/src/Core/Repositories/DocumentDB/Utilities/DocumentClientHelpers.cs new file mode 100644 index 000000000..250ef139e --- /dev/null +++ b/src/Core/Repositories/DocumentDB/Utilities/DocumentClientHelpers.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.Azure.Documents.Client; + +namespace Bit.Core.Repositories.DocumentDB.Utilities +{ + public class DocumentClientHelpers + { + public static DocumentClient InitClient(GlobalSettings.DocumentDBSettings settings) + { + var client = new DocumentClient( + new Uri(settings.Uri), + settings.Key, + new ConnectionPolicy + { + ConnectionMode = ConnectionMode.Direct, + ConnectionProtocol = Protocol.Tcp + }); + + var hashResolver = new ManagedHashPartitionResolver( + GetPartitionKeyExtractor(), + settings.DatabaseId, + settings.CollectionIdPrefix, + settings.NumberOfCollections, + null); + + client.PartitionResolvers[UriFactory.CreateDatabaseUri(settings.DatabaseId).OriginalString] = hashResolver; + client.OpenAsync().Wait(); + + return client; + } + + private static Func GetPartitionKeyExtractor() + { + return doc => + { + if(doc is Domains.User) + { + return ((Domains.User)doc).Id; + } + + if(doc is Domains.Cipher) + { + return ((Domains.Cipher)doc).UserId; + } + + throw new InvalidOperationException("Document type is not resolvable for the partition key extractor."); + }; + } + } +} diff --git a/src/Core/Repositories/DocumentDB/Utilities/ManagedHashPartitionResolver.cs b/src/Core/Repositories/DocumentDB/Utilities/ManagedHashPartitionResolver.cs new file mode 100644 index 000000000..47394045c --- /dev/null +++ b/src/Core/Repositories/DocumentDB/Utilities/ManagedHashPartitionResolver.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Documents.Partitioning; + +namespace Bit.Core.Repositories.DocumentDB.Utilities +{ + public class ManagedHashPartitionResolver : HashPartitionResolver + { + public ManagedHashPartitionResolver( + Func partitionKeyExtractor, + string databaseId, + string collectionIdPrefix, + int numberOfCollections, + IHashGenerator hashGenerator = null) + : base( + partitionKeyExtractor, + GetCollectionIds(databaseId, collectionIdPrefix, numberOfCollections), + 128, + hashGenerator) + { } + + private static List GetCollectionIds(string databaseId, string collectionIdPrefix, int numberOfCollections) + { + var collections = new List(); + for(int i = 0; i < numberOfCollections; i++) + { + var collectionIdUri = UriFactory.CreateDocumentCollectionUri(databaseId, string.Concat(collectionIdPrefix, i)); + collections.Add(collectionIdUri.OriginalString); + } + + return collections; + } + } +} diff --git a/src/Core/Repositories/ICipherRepository.cs b/src/Core/Repositories/ICipherRepository.cs new file mode 100644 index 000000000..36246177a --- /dev/null +++ b/src/Core/Repositories/ICipherRepository.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Bit.Core.Repositories +{ + public interface ICipherRepository + { + Task UpdateDirtyCiphersAsync(IEnumerable ciphers); + } +} diff --git a/src/Core/Repositories/IFolderRepository.cs b/src/Core/Repositories/IFolderRepository.cs new file mode 100644 index 000000000..916f370ac --- /dev/null +++ b/src/Core/Repositories/IFolderRepository.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Domains; + +namespace Bit.Core.Repositories +{ + public interface IFolderRepository : IRepository + { + Task GetByIdAsync(string id, string userId); + Task> GetManyByUserIdAsync(string userId); + Task> GetManyByUserIdAsync(string userId, bool dirty); + } +} diff --git a/src/Core/Repositories/IRepository.cs b/src/Core/Repositories/IRepository.cs new file mode 100644 index 000000000..0ac498d59 --- /dev/null +++ b/src/Core/Repositories/IRepository.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Bit.Core.Repositories +{ + public interface IRepository where T : IDataObject + { + Task GetByIdAsync(string id); + Task CreateAsync(T obj); + Task ReplaceAsync(T obj); + Task UpsertAsync(T obj); + Task DeleteByIdAsync(string id); + Task DeleteAsync(T obj); + } +} diff --git a/src/Core/Repositories/ISiteRepository.cs b/src/Core/Repositories/ISiteRepository.cs new file mode 100644 index 000000000..9e734a24f --- /dev/null +++ b/src/Core/Repositories/ISiteRepository.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Domains; + +namespace Bit.Core.Repositories +{ + public interface ISiteRepository : IRepository + { + Task GetByIdAsync(string id, string userId); + Task> GetManyByUserIdAsync(string userId); + Task> GetManyByUserIdAsync(string userId, bool dirty); + } +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs new file mode 100644 index 000000000..606b2e160 --- /dev/null +++ b/src/Core/Repositories/IUserRepository.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Domains; + +namespace Bit.Core.Repositories +{ + public interface IUserRepository : IRepository + { + Task GetByEmailAsync(string email); + Task ReplaceAndDirtyCiphersAsync(User user); + } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs new file mode 100644 index 000000000..abd73ed18 --- /dev/null +++ b/src/Core/Services/IMailService.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using Bit.Core.Domains; + +namespace Bit.Core.Services +{ + public interface IMailService + { + Task SendAlreadyRegisteredEmailAsync(string registrantEmailAddress); + Task SendRegisterEmailAsync(string registrantEmailAddress, string token); + Task SendWelcomeEmailAsync(User user); + Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); + Task SendChangeEmailEmailAsync(string newEmailAddress, string token); + Task SendNoMasterPasswordHintEmailAsync(string email); + Task SendMasterPasswordHintEmailAsync(string email, string hint); + } +} \ No newline at end of file diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs new file mode 100644 index 000000000..6124c8ae7 --- /dev/null +++ b/src/Core/Services/IUserService.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; +using Bit.Core.Domains; + +namespace Bit.Core.Services +{ + public interface IUserService + { + Task GetUserByIdAsync(string userId); + Task SaveUserAsync(User user); + Task InitiateRegistrationAsync(string email); + Task RegisterUserAsync(string token, User user, string masterPassword); + Task SendMasterPasswordHintAsync(string email); + Task InitiateEmailChangeAsync(User user, string newEmail); + Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, IEnumerable ciphers); + Task ChangePasswordAsync(User user, string currentMasterPasswordHash, string newMasterPasswordHash, IEnumerable ciphers); + Task RefreshSecurityStampAsync(User user, string masterPasswordHash); + Task GetTwoFactorAsync(User user, Enums.TwoFactorProvider provider); + } +} diff --git a/src/Core/Services/MailService.cs b/src/Core/Services/MailService.cs new file mode 100644 index 000000000..b741573b3 --- /dev/null +++ b/src/Core/Services/MailService.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Net.Mail; +using System.Threading.Tasks; +using Bit.Core.Domains; +using SendGrid; + +namespace Bit.Core.Services +{ + public class MailService : IMailService + { + private const string AlreadyRegisteredTemplateId = "8af9cd2b-e4dd-497a-bcc6-1d5b317ff811"; + private const string RegisterTemplateId = "7382e1f9-50c7-428d-aa06-bf584f03cd6a"; + private const string WelcomeTemplateId = "d24aa21e-5ead-45d8-a14e-f96ba7ec63ff"; + private const string ChangeEmailAlreadyExistsTemplateId = "b28bc69e-9592-4320-b274-bfb955667add"; + private const string ChangeEmailTemplateId = "b8d17dd7-c883-4b47-8170-5b845d487929"; + private const string NoMasterPasswordHint = "d5d13bba-3f67-4899-9995-514c1bd6dae7"; + private const string MasterPasswordHint = "804a9897-1284-42e8-8aed-ab318c378b71"; + + private const string AdministrativeCategoryName = "Administrative"; + private const string MarketingCategoryName = "Marketing"; + + private readonly GlobalSettings _globalSettings; + private readonly Web _web; + + public MailService(GlobalSettings globalSettings) + { + _globalSettings = globalSettings; + _web = new Web(_globalSettings.Mail.APIKey); + } + + public async Task SendAlreadyRegisteredEmailAsync(string registrantEmailAddress) + { + var message = CreateDefaultMessage(AlreadyRegisteredTemplateId); + + message.Subject = "Your Registration"; + message.AddTo(registrantEmailAddress); + message.AddSubstitution("{{email}}", new List { registrantEmailAddress }); + message.SetCategories(new List { AdministrativeCategoryName, "Already Registered" }); + + await _web.DeliverAsync(message); + } + + public async Task SendRegisterEmailAsync(string registrantEmailAddress, string token) + { + var message = CreateDefaultMessage(RegisterTemplateId); + + message.Subject = "Complete Your Registration"; + message.AddTo(registrantEmailAddress); + message.AddSubstitution("{{token}}", new List { Uri.EscapeDataString(token) }); + message.AddSubstitution("{{email}}", new List { Uri.EscapeDataString(registrantEmailAddress) }); + message.SetCategories(new List { AdministrativeCategoryName, "Register" }); + message.DisableBypassListManagement(); + + await _web.DeliverAsync(message); + } + + public async Task SendWelcomeEmailAsync(User user) + { + var message = CreateDefaultMessage(WelcomeTemplateId); + + message.Subject = "Welcome"; + message.AddTo(user.Email); + message.SetCategories(new List { AdministrativeCategoryName, "Welcome" }); + + await _web.DeliverAsync(message); + } + + public async Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail) + { + var message = CreateDefaultMessage(ChangeEmailAlreadyExistsTemplateId); + + message.Subject = "Your Email Change"; + message.AddTo(toEmail); + message.AddSubstitution("{{fromEmail}}", new List { fromEmail }); + message.AddSubstitution("{{toEmail}}", new List { toEmail }); + message.SetCategories(new List { AdministrativeCategoryName, "Change Email Alrady Exists" }); + + await _web.DeliverAsync(message); + } + + public async Task SendChangeEmailEmailAsync(string newEmailAddress, string token) + { + var message = CreateDefaultMessage(ChangeEmailTemplateId); + + message.Subject = "Change Your Email"; + message.AddTo(newEmailAddress); + message.AddSubstitution("{{token}}", new List { Uri.EscapeDataString(token) }); + message.SetCategories(new List { AdministrativeCategoryName, "Change Email" }); + message.DisableBypassListManagement(); + + await _web.DeliverAsync(message); + } + + public async Task SendNoMasterPasswordHintEmailAsync(string email) + { + var message = CreateDefaultMessage(NoMasterPasswordHint); + + message.Subject = "Your Master Password Hint"; + message.AddTo(email); + message.SetCategories(new List { AdministrativeCategoryName, "No Master Password Hint" }); + + await _web.DeliverAsync(message); + } + + public async Task SendMasterPasswordHintEmailAsync(string email, string hint) + { + var message = CreateDefaultMessage(MasterPasswordHint); + + message.Subject = "Your Master Password Hint"; + message.AddTo(email); + message.AddSubstitution("{{hint}}", new List { hint }); + message.SetCategories(new List { AdministrativeCategoryName, "Master Password Hint" }); + + await _web.DeliverAsync(message); + } + + private SendGridMessage CreateDefaultMessage(string templateId) + { + var message = new SendGridMessage + { + From = new MailAddress(_globalSettings.Mail.ReplyToEmail, _globalSettings.SiteName), + Html = " ", + Text = " " + }; + + if(!string.IsNullOrWhiteSpace(templateId)) + { + message.EnableTemplateEngine(templateId); + } + + message.AddSubstitution("{{siteName}}", new List { _globalSettings.SiteName }); + message.AddSubstitution("{{baseVaultUri}}", new List { _globalSettings.BaseVaultUri }); + + return message; + } + } +} diff --git a/src/Core/Services/UserService.cs b/src/Core/Services/UserService.cs new file mode 100644 index 000000000..65ac4093a --- /dev/null +++ b/src/Core/Services/UserService.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNet.DataProtection; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.OptionsModel; +using Bit.Core.Domains; +using Bit.Core.Repositories; +using OtpSharp; +using Base32; +using System.Linq; + +namespace Bit.Core.Services +{ + public class UserService : UserManager, IUserService, IDisposable + { + private readonly IUserRepository _userRepository; + private readonly ICipherRepository _cipherRepository; + private readonly IMailService _mailService; + private readonly ITimeLimitedDataProtector _registrationEmailDataProtector; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly IdentityOptions _identityOptions; + private readonly IPasswordHasher _passwordHasher; + private readonly IEnumerable> _passwordValidators; + + public UserService( + IUserRepository userRepository, + ICipherRepository cipherRepository, + IMailService mailService, + IDataProtectionProvider dataProtectionProvider, + IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger, + IHttpContextAccessor contextAccessor) + : base( + store, + optionsAccessor, + passwordHasher, + userValidators, + passwordValidators, + keyNormalizer, + errors, + services, + logger, + contextAccessor) + { + _userRepository = userRepository; + _cipherRepository = cipherRepository; + _mailService = mailService; + _registrationEmailDataProtector = dataProtectionProvider.CreateProtector("RegistrationEmail").ToTimeLimitedDataProtector(); + _identityOptions = optionsAccessor?.Value ?? new IdentityOptions(); + _identityErrorDescriber = errors; + _passwordHasher = passwordHasher; + _passwordValidators = passwordValidators; + } + + public async Task GetUserByIdAsync(string userId) + { + return await _userRepository.GetByIdAsync(userId); + } + + public async Task SaveUserAsync(User user) + { + if(string.IsNullOrWhiteSpace(user.Id)) + { + throw new ApplicationException("Use register method to create a new user."); + } + + await _userRepository.ReplaceAsync(user); + } + + public async Task InitiateRegistrationAsync(string email) + { + var existingUser = await _userRepository.GetByEmailAsync(email); + if(existingUser != null) + { + await _mailService.SendAlreadyRegisteredEmailAsync(email); + return; + } + + var token = _registrationEmailDataProtector.Protect(email, TimeSpan.FromDays(5)); + await _mailService.SendRegisterEmailAsync(email, token); + } + + public async Task RegisterUserAsync(string token, User user, string masterPassword) + { + try + { + var tokenEmail = _registrationEmailDataProtector.Unprotect(token); + if(tokenEmail != user.Email) + { + return IdentityResult.Failed(_identityErrorDescriber.InvalidToken()); + } + } + catch + { + return IdentityResult.Failed(_identityErrorDescriber.InvalidToken()); + } + + var result = await base.CreateAsync(user, masterPassword); + if(result == IdentityResult.Success) + { + await _mailService.SendWelcomeEmailAsync(user); + } + + return result; + } + + public async Task SendMasterPasswordHintAsync(string email) + { + var user = await _userRepository.GetByEmailAsync(email); + if(user == null) + { + // No user exists. Do we want to send an email telling them this in the future? + return; + } + + if(string.IsNullOrWhiteSpace(user.MasterPasswordHint)) + { + await _mailService.SendNoMasterPasswordHintEmailAsync(email); + } + + await _mailService.SendMasterPasswordHintEmailAsync(email, user.MasterPasswordHint); + } + + public async Task InitiateEmailChangeAsync(User user, string newEmail) + { + var existingUser = await _userRepository.GetByEmailAsync(newEmail); + if(existingUser != null) + { + await _mailService.SendChangeEmailAlreadyExistsEmailAsync(user.Email, newEmail); + return; + } + + var token = await base.GenerateChangeEmailTokenAsync(user, newEmail); + await _mailService.SendChangeEmailEmailAsync(newEmail, token); + } + + public async Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, IEnumerable ciphers) + { + var verifyPasswordResult = _passwordHasher.VerifyHashedPassword(user, user.MasterPassword, masterPassword); + if(verifyPasswordResult == PasswordVerificationResult.Failed) + { + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + if(!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider, GetChangeEmailTokenPurpose(newEmail), token)) + { + return IdentityResult.Failed(_identityErrorDescriber.InvalidToken()); + } + + var existingUser = await _userRepository.GetByEmailAsync(newEmail); + if(existingUser != null) + { + return IdentityResult.Failed(_identityErrorDescriber.DuplicateEmail(newEmail)); + } + + user.OldEmail = user.Email; + user.OldMasterPassword = user.MasterPassword; + user.Email = newEmail; + user.MasterPassword = _passwordHasher.HashPassword(user, newMasterPassword); + user.SecurityStamp = Guid.NewGuid().ToString(); + + await _userRepository.ReplaceAndDirtyCiphersAsync(user); + await _cipherRepository.UpdateDirtyCiphersAsync(ciphers); + + // TODO: what if something fails? rollback? + + return IdentityResult.Success; + } + + public override Task ChangePasswordAsync(User user, string currentMasterPasswordHash, string newMasterPasswordHash) + { + throw new NotImplementedException(); + } + + public async Task ChangePasswordAsync(User user, string currentMasterPasswordHash, string newMasterPasswordHash, IEnumerable ciphers) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if(await base.CheckPasswordAsync(user, currentMasterPasswordHash)) + { + var result = await UpdatePasswordHash(user, newMasterPasswordHash); + if(!result.Succeeded) + { + return result; + } + + await _userRepository.ReplaceAndDirtyCiphersAsync(user); + await _cipherRepository.UpdateDirtyCiphersAsync(ciphers); + + // TODO: what if something fails? rollback? + + return IdentityResult.Success; + } + + Logger.LogWarning("Change password failed for user {userId}.", user.Id); + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + public async Task RefreshSecurityStampAsync(User user, string masterPasswordHash) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if(await base.CheckPasswordAsync(user, masterPasswordHash)) + { + var result = await base.UpdateSecurityStampAsync(user); + if(!result.Succeeded) + { + return result; + } + + await _userRepository.ReplaceAndDirtyCiphersAsync(user); + return IdentityResult.Success; + } + + Logger.LogWarning("Refresh security stamp failed for user {userId}.", user.Id); + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + public async Task GetTwoFactorAsync(User user, Enums.TwoFactorProvider provider) + { + if(user.TwoFactorEnabled && user.TwoFactorProvider.HasValue && user.TwoFactorProvider.Value == provider) + { + switch(provider) + { + case Enums.TwoFactorProvider.Authenticator: + if(!string.IsNullOrWhiteSpace(user.AuthenticatorKey)) + { + return; + } + break; + default: + throw new ArgumentException(nameof(provider)); + } + } + + user.TwoFactorProvider = provider; + // Reset authenticator key. + user.AuthenticatorKey = null; + + switch(provider) + { + case Enums.TwoFactorProvider.Authenticator: + var key = KeyGeneration.GenerateRandomKey(20); + user.AuthenticatorKey = Base32Encoder.Encode(key); + break; + default: + throw new ArgumentException(nameof(provider)); + } + + await _userRepository.ReplaceAsync(user); + } + + private async Task UpdatePasswordHash(User user, string newPassword, bool validatePassword = true) + { + if(validatePassword) + { + var validate = await ValidatePasswordInternal(user, newPassword); + if(!validate.Succeeded) + { + return validate; + } + } + + user.OldMasterPassword = user.MasterPassword; + user.MasterPassword = _passwordHasher.HashPassword(user, newPassword); + user.SecurityStamp = Guid.NewGuid().ToString(); + + return IdentityResult.Success; + } + + private async Task ValidatePasswordInternal(User user, string password) + { + var errors = new List(); + foreach(var v in _passwordValidators) + { + var result = await v.ValidateAsync(this, user, password); + if(!result.Succeeded) + { + errors.AddRange(result.Errors); + } + } + + if(errors.Count > 0) + { + Logger.LogWarning("User {userId} password validation failed: {errors}.", await GetUserIdAsync(user), string.Join(";", errors.Select(e => e.Code))); + return IdentityResult.Failed(errors.ToArray()); + } + + return IdentityResult.Success; + } + } +} diff --git a/src/Core/project.json b/src/Core/project.json new file mode 100644 index 000000000..585f392ed --- /dev/null +++ b/src/Core/project.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1-*", + "description": "bitwarden Core Library", + "authors": [ "Kyle Spearrin" ], + "tags": [ "" ], + "projectUrl": "", + "licenseUrl": "", + + "dependencies": { + "Microsoft.AspNet.Identity": "3.0.0-rc1-final", + "Microsoft.AspNet.Authentication.JwtBearer": "1.0.0-rc1-final", + "Microsoft.Azure.DocumentDB": "1.5.1", + "Newtonsoft.Json": "7.0.1", + "OtpSharp": "1.3.0.4", + "Microsoft.AspNet.Mvc.Abstractions": "6.0.0-rc1-final", + "Sendgrid": "6.3.0", + "Microsoft.AspNet.DataProtection.Extensions": "1.0.0-rc1-final" + }, + + "frameworks": { + "dnx451": { } + } +}