diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 5f3dd21843..12ff1d2a0b 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -20,6 +20,7 @@ namespace Bit.Api.Controllers public class AccountsController : Controller { private readonly IUserService _userService; + private readonly IUserRepository _userRepository; private readonly ICipherService _cipherService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILicensingService _licenseService; @@ -27,18 +28,32 @@ namespace Bit.Api.Controllers public AccountsController( IUserService userService, + IUserRepository userRepository, ICipherService cipherService, IOrganizationUserRepository organizationUserRepository, ILicensingService licenseService, GlobalSettings globalSettings) { _userService = userService; + _userRepository = userRepository; _cipherService = cipherService; _organizationUserRepository = organizationUserRepository; _licenseService = licenseService; _globalSettings = globalSettings; } + [HttpPost("prelogin")] + [AllowAnonymous] + public async Task PostPrelogin([FromBody]PreloginRequestModel model) + { + var kdfInformation = await _userRepository.GetKdfInformationByEmailAsync(model.Email); + if(kdfInformation == null) + { + throw new NotFoundException(); + } + return new PreloginResponseModel(kdfInformation); + } + [HttpPost("register")] [AllowAnonymous] public async Task PostRegister([FromBody]RegisterRequestModel model) @@ -170,6 +185,31 @@ namespace Bit.Api.Controllers throw new BadRequestException(ModelState); } + [HttpPost("kdf")] + public async Task PostKdf([FromBody]KdfRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if(user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.ChangeKdfAsync(user, model.MasterPasswordHash, + model.NewMasterPasswordHash, model.Key, model.Kdf.Value, model.KdfIterations.Value); + 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("key")] public async Task PostKey([FromBody]UpdateKeyRequestModel model) { diff --git a/src/Core/Enums/KdfType.cs b/src/Core/Enums/KdfType.cs new file mode 100644 index 0000000000..0cfaeab8c0 --- /dev/null +++ b/src/Core/Enums/KdfType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Enums +{ + public enum KdfType : byte + { + PBKDF2 = 0 + } +} diff --git a/src/Core/Models/Api/Request/Accounts/KdfRequestModel.cs b/src/Core/Models/Api/Request/Accounts/KdfRequestModel.cs new file mode 100644 index 0000000000..04b5529fa7 --- /dev/null +++ b/src/Core/Models/Api/Request/Accounts/KdfRequestModel.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; + +namespace Bit.Core.Models.Api +{ + public class KdfRequestModel : PasswordRequestModel, IValidatableObject + { + [Required] + public KdfType? Kdf { get; set; } + [Required] + public int? KdfIterations { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if(Kdf.HasValue && KdfIterations.HasValue) + { + switch(Kdf.Value) + { + case KdfType.PBKDF2: + if(KdfIterations.Value < 5000 || KdfIterations.Value > 1_000_000) + { + yield return new ValidationResult("KDF iterations must be between 5000 and 1000000."); + } + break; + default: + break; + } + } + } + } +} diff --git a/src/Core/Models/Api/Request/Accounts/PreloginRequestModel.cs b/src/Core/Models/Api/Request/Accounts/PreloginRequestModel.cs new file mode 100644 index 0000000000..7ac1386dba --- /dev/null +++ b/src/Core/Models/Api/Request/Accounts/PreloginRequestModel.cs @@ -0,0 +1,13 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Models.Api +{ + public class PreloginRequestModel + { + [Required] + [EmailAddress] + [StringLength(50)] + public string Email { get; set; } + } +} diff --git a/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs b/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs index a27d9ef0ea..ef31099047 100644 --- a/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs +++ b/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; using Bit.Core.Models.Table; namespace Bit.Core.Models.Api { - public class RegisterRequestModel + public class RegisterRequestModel : IValidatableObject { [StringLength(50)] public string Name { get; set; } @@ -21,6 +23,8 @@ namespace Bit.Core.Models.Api public KeysRequestModel Keys { get; set; } public string Token { get; set; } public Guid? OrganizationUserId { get; set; } + public KdfType? Kdf { get; set; } + public int? KdfIterations { get; set; } public User ToUser() { @@ -28,7 +32,9 @@ namespace Bit.Core.Models.Api { Name = Name, Email = Email, - MasterPasswordHint = MasterPasswordHint + MasterPasswordHint = MasterPasswordHint, + Kdf = Kdf.GetValueOrDefault(KdfType.PBKDF2), + KdfIterations = KdfIterations.GetValueOrDefault(5000) }; if(Key != null) @@ -43,5 +49,23 @@ namespace Bit.Core.Models.Api return user; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if(Kdf.HasValue && KdfIterations.HasValue) + { + switch(Kdf.Value) + { + case KdfType.PBKDF2: + if(KdfIterations.Value < 5000 || KdfIterations.Value > 1_000_000) + { + yield return new ValidationResult("KDF iterations must be between 5000 and 1000000."); + } + break; + default: + break; + } + } + } } } diff --git a/src/Core/Models/Api/Response/PreloginResponseModel.cs b/src/Core/Models/Api/Response/PreloginResponseModel.cs new file mode 100644 index 0000000000..eedae84da9 --- /dev/null +++ b/src/Core/Models/Api/Response/PreloginResponseModel.cs @@ -0,0 +1,17 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Core.Models.Api +{ + public class PreloginResponseModel + { + public PreloginResponseModel(UserKdfInformation kdfInformation) + { + Kdf = kdfInformation.Kdf; + KdfIterations = kdfInformation.KdfIterations; + } + + public KdfType Kdf { get; set; } + public int KdfIterations { get; set; } + } +} diff --git a/src/Core/Models/Data/UserKdfInformation.cs b/src/Core/Models/Data/UserKdfInformation.cs new file mode 100644 index 0000000000..82562c234c --- /dev/null +++ b/src/Core/Models/Data/UserKdfInformation.cs @@ -0,0 +1,11 @@ +using System; +using Bit.Core.Enums; + +namespace Bit.Core.Models.Data +{ + public class UserKdfInformation + { + public KdfType Kdf { get; set; } + public int KdfIterations { get; set; } + } +} diff --git a/src/Core/Models/Table/User.cs b/src/Core/Models/Table/User.cs index 13c839ffbd..ea0860dee0 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -39,6 +39,8 @@ namespace Bit.Core.Models.Table public string GatewayCustomerId { get; set; } public string GatewaySubscriptionId { get; set; } public string LicenseKey { get; set; } + public KdfType Kdf { get; set; } = KdfType.PBKDF2; + public int KdfIterations { get; set; } = 5000; public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 975f3edbb3..df16428461 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Bit.Core.Models.Data; using Bit.Core.Models.Table; namespace Bit.Core.Repositories @@ -8,6 +9,7 @@ namespace Bit.Core.Repositories public interface IUserRepository : IRepository { Task GetByEmailAsync(string email); + Task GetKdfInformationByEmailAsync(string email); Task> SearchAsync(string email, int skip, int take); Task> GetManyByPremiumAsync(bool premium); Task> GetManyByPremiumRenewalAsync(); diff --git a/src/Core/Repositories/SqlServer/UserRepository.cs b/src/Core/Repositories/SqlServer/UserRepository.cs index bea608bcc0..fb56bc10e9 100644 --- a/src/Core/Repositories/SqlServer/UserRepository.cs +++ b/src/Core/Repositories/SqlServer/UserRepository.cs @@ -4,6 +4,7 @@ using System.Data; using System.Data.SqlClient; using System.Linq; using System.Threading.Tasks; +using Bit.Core.Models.Data; using Bit.Core.Models.Table; using Dapper; @@ -37,6 +38,19 @@ namespace Bit.Core.Repositories.SqlServer } } + public async Task GetKdfInformationByEmailAsync(string email) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadKdfByEmail]", + new { Email = email }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } + public async Task> SearchAsync(string email, int skip, int take) { using(var connection = new SqlConnection(ConnectionString)) diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 88e39b259d..8eb31eeb95 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -30,6 +30,8 @@ namespace Bit.Core.Services Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, string key); Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string key); + Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, + KdfType kdf, int kdfIterations); Task UpdateKeyAsync(User user, string masterPassword, string key, string privateKey, IEnumerable ciphers, IEnumerable folders); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index cd424e22c5..6ba1d6ce9b 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -446,6 +446,34 @@ namespace Bit.Core.Services return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } + public async Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, + string key, KdfType kdf, int kdfIterations) + { + if(user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if(await CheckPasswordAsync(user, masterPassword)) + { + var result = await UpdatePasswordHash(user, newMasterPassword); + if(!result.Succeeded) + { + return result; + } + + user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; + user.Key = key; + user.Kdf = kdf; + user.KdfIterations = kdfIterations; + await _userRepository.ReplaceAsync(user); + return IdentityResult.Success; + } + + Logger.LogWarning("Change KDF failed for user {userId}.", user.Id); + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + public async Task UpdateKeyAsync(User user, string masterPassword, string key, string privateKey, IEnumerable ciphers, IEnumerable folders) { @@ -477,7 +505,7 @@ namespace Bit.Core.Services return IdentityResult.Success; } - Logger.LogWarning("Update key for user {userId}.", user.Id); + Logger.LogWarning("Update key failed for user {userId}.", user.Id); return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index ddb4c8226e..3838f26cf2 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -230,5 +230,6 @@ + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_Create.sql b/src/Sql/dbo/Stored Procedures/User_Create.sql index 292677f6dc..5c19dfe88a 100644 --- a/src/Sql/dbo/Stored Procedures/User_Create.sql +++ b/src/Sql/dbo/Stored Procedures/User_Create.sql @@ -24,6 +24,8 @@ @GatewayCustomerId VARCHAR(50), @GatewaySubscriptionId VARCHAR(50), @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -57,6 +59,8 @@ BEGIN [GatewayCustomerId], [GatewaySubscriptionId], [LicenseKey], + [Kdf], + [KdfIterations], [CreationDate], [RevisionDate] ) @@ -87,6 +91,8 @@ BEGIN @GatewayCustomerId, @GatewaySubscriptionId, @LicenseKey, + @Kdf, + @KdfIterations, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Stored Procedures/User_ReadKdfByEmail.sql b/src/Sql/dbo/Stored Procedures/User_ReadKdfByEmail.sql new file mode 100644 index 0000000000..a58634e0a8 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadKdfByEmail.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[User_ReadKdfByEmail] + @Email NVARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Kdf], + [KdfIterations] + FROM + [dbo].[User] + WHERE + [Email] = @Email +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_Update.sql b/src/Sql/dbo/Stored Procedures/User_Update.sql index 39ad5462eb..32a9d34676 100644 --- a/src/Sql/dbo/Stored Procedures/User_Update.sql +++ b/src/Sql/dbo/Stored Procedures/User_Update.sql @@ -24,6 +24,8 @@ @GatewayCustomerId VARCHAR(50), @GatewaySubscriptionId VARCHAR(50), @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -57,6 +59,8 @@ BEGIN [GatewayCustomerId] = @GatewayCustomerId, [GatewaySubscriptionId] = @GatewaySubscriptionId, [LicenseKey] = @LicenseKey, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Tables/User.sql b/src/Sql/dbo/Tables/User.sql index 7cad9056ae..fcb661c485 100644 --- a/src/Sql/dbo/Tables/User.sql +++ b/src/Sql/dbo/Tables/User.sql @@ -24,6 +24,8 @@ [GatewayCustomerId] VARCHAR (50) NULL, [GatewaySubscriptionId] VARCHAR (50) NULL, [LicenseKey] VARCHAR (100) NULL, + [Kdf] TINYINT NOT NULL, + [KdfIterations] INT NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL, [RevisionDate] DATETIME2 (7) NOT NULL, CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ([Id] ASC) diff --git a/util/Setup/DbScripts/2018-08-14_00_UserKdf.sql b/util/Setup/DbScripts/2018-08-14_00_UserKdf.sql new file mode 100644 index 0000000000..50b0a3f05a --- /dev/null +++ b/util/Setup/DbScripts/2018-08-14_00_UserKdf.sql @@ -0,0 +1,247 @@ +IF COL_LENGTH('[dbo].[User]', 'Kdf') IS NULL +BEGIN + ALTER TABLE + [dbo].[User] + ADD + [Kdf] TINYINT NULL, + [KdfIterations] INT NULL +END +GO + +UPDATE + [dbo].[User] +SET + [Kdf] = 0, + [KdfIterations] = 5000 +GO + +ALTER TABLE + [dbo].[User] +ALTER COLUMN + [Kdf] TINYINT NOT NULL +GO + +ALTER TABLE + [dbo].[User] +ALTER COLUMN + [KdfIterations] INT NOT NULL +GO + +IF EXISTS(SELECT * FROM sys.views WHERE [Name] = 'UserView') +BEGIN + DROP VIEW [dbo].[UserView] +END +GO + +CREATE VIEW [dbo].[UserView] +AS +SELECT + * +FROM + [dbo].[User] +GO + +IF OBJECT_ID('[dbo].[User_Create]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_Create] +END +GO + +CREATE PROCEDURE [dbo].[User_Create] + @Id UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @Email NVARCHAR(50), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[User] + ( + [Id], + [Name], + [Email], + [EmailVerified], + [MasterPassword], + [MasterPasswordHint], + [Culture], + [SecurityStamp], + [TwoFactorProviders], + [TwoFactorRecoveryCode], + [EquivalentDomains], + [ExcludedGlobalEquivalentDomains], + [AccountRevisionDate], + [Key], + [PublicKey], + [PrivateKey], + [Premium], + [PremiumExpirationDate], + [RenewalReminderDate], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [LicenseKey], + [Kdf], + [KdfIterations], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @Name, + @Email, + @EmailVerified, + @MasterPassword, + @MasterPasswordHint, + @Culture, + @SecurityStamp, + @TwoFactorProviders, + @TwoFactorRecoveryCode, + @EquivalentDomains, + @ExcludedGlobalEquivalentDomains, + @AccountRevisionDate, + @Key, + @PublicKey, + @PrivateKey, + @Premium, + @PremiumExpirationDate, + @RenewalReminderDate, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @LicenseKey, + @Kdf, + @KdfIterations, + @CreationDate, + @RevisionDate + ) +END +GO + +IF OBJECT_ID('[dbo].[User_Update]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_Update] +END +GO + +CREATE PROCEDURE [dbo].[User_Update] + @Id UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @Email NVARCHAR(50), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Name] = @Name, + [Email] = @Email, + [EmailVerified] = @EmailVerified, + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Culture] = @Culture, + [SecurityStamp] = @SecurityStamp, + [TwoFactorProviders] = @TwoFactorProviders, + [TwoFactorRecoveryCode] = @TwoFactorRecoveryCode, + [EquivalentDomains] = @EquivalentDomains, + [ExcludedGlobalEquivalentDomains] = @ExcludedGlobalEquivalentDomains, + [AccountRevisionDate] = @AccountRevisionDate, + [Key] = @Key, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [Premium] = @Premium, + [PremiumExpirationDate] = @PremiumExpirationDate, + [RenewalReminderDate] = @RenewalReminderDate, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [LicenseKey] = @LicenseKey, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +IF OBJECT_ID('[dbo].[User_ReadKdfByEmail]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_ReadKdfByEmail] +END +GO + +CREATE PROCEDURE [dbo].[User_ReadKdfByEmail] + @Email NVARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Kdf], + [KdfIterations] + FROM + [dbo].[User] + WHERE + [Email] = @Email +END +GO