diff --git a/src/App/App.csproj b/src/App/App.csproj index c60580e2d..8dbcc000f 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -125,6 +125,9 @@ LoginPasswordlessPage.xaml + + LoginPasswordlessRequestPage.xaml + diff --git a/src/App/Controls/IconLabelButton/IconLabelButton.xaml b/src/App/Controls/IconLabelButton/IconLabelButton.xaml index 5b88dcd91..73af0751e 100644 --- a/src/App/Controls/IconLabelButton/IconLabelButton.xaml +++ b/src/App/Controls/IconLabelButton/IconLabelButton.xaml @@ -8,6 +8,7 @@ Padding="1" StyleClass="btn-icon-secondary" BackgroundColor="{Binding IconLabelBorderColor, Source={x:Reference _iconLabelButton}}" + BorderColor="Transparent" HasShadow="False"> @@ -17,6 +18,7 @@ Padding="0" CornerRadius="{Binding CornerRadius, Source={x:Reference _iconLabelButton}}" BackgroundColor="{Binding IconLabelBackgroundColor, Source={x:Reference _iconLabelButton}}" + BorderColor="Transparent" IsClippedToBounds="True" HasShadow="False"> + ButtonCommand="{Binding LogInWithDeviceCommand}" + IsVisible="{Binding IsKnownDevice}"/> Device.BeginInvokeOnMainThread(async () => await StartTwoFactorAsync()); _vm.LogInSuccessAction = () => Device.BeginInvokeOnMainThread(async () => await LogInSuccessAsync()); + _vm.LogInWithDeviceAction = () => StartLoginWithDeviceAsync().FireAndForget(); _vm.StartSsoLoginAction = () => Device.BeginInvokeOnMainThread(async () => await StartSsoLoginAsync()); - _vm.UpdateTempPasswordAction = - () => Device.BeginInvokeOnMainThread(async () => await UpdateTempPasswordAsync()); + _vm.UpdateTempPasswordAction = () => Device.BeginInvokeOnMainThread(async () => await UpdateTempPasswordAsync()); _vm.CloseAction = async () => { await _accountListOverlay.HideAsync(); @@ -122,6 +122,12 @@ namespace Bit.App.Pages } } + private async Task StartLoginWithDeviceAsync() + { + var page = new LoginPasswordlessRequestPage(_vm.Email, _appOptions); + await Navigation.PushModalAsync(new NavigationPage(page)); + } + private async Task StartSsoLoginAsync() { var page = new LoginSsoPage(_appOptions); diff --git a/src/App/Pages/Accounts/LoginPageViewModel.cs b/src/App/Pages/Accounts/LoginPageViewModel.cs index 9bf0ebe2a..0dbcd07c1 100644 --- a/src/App/Pages/Accounts/LoginPageViewModel.cs +++ b/src/App/Pages/Accounts/LoginPageViewModel.cs @@ -54,6 +54,7 @@ namespace Bit.App.Pages TogglePasswordCommand = new Command(TogglePassword); LogInCommand = new Command(async () => await LogInAsync()); MoreCommand = new AsyncCommand(MoreAsync, onException: _logger.Exception, allowsMultipleExecutions: false); + LogInWithDeviceCommand = new AsyncCommand(() => Device.InvokeOnMainThreadAsync(LogInWithDeviceAction), onException: _logger.Exception, allowsMultipleExecutions: false); AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger) { @@ -114,11 +115,13 @@ namespace Bit.App.Pages public Command LogInCommand { get; } public Command TogglePasswordCommand { get; } public ICommand MoreCommand { get; internal set; } + public ICommand LogInWithDeviceCommand { get; } public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye; public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow; public string LoggingInAsText => string.Format(AppResources.LoggingInAsX, Email); public Action StartTwoFactorAction { get; set; } public Action LogInSuccessAction { get; set; } + public Action LogInWithDeviceAction { get; set; } public Action UpdateTempPasswordAction { get; set; } public Action StartSsoLoginAction { get; set; } public Action CloseAction { get; set; } diff --git a/src/App/Pages/Accounts/LoginPasswordlessRequestPage.xaml b/src/App/Pages/Accounts/LoginPasswordlessRequestPage.xaml new file mode 100644 index 000000000..d6ea864f0 --- /dev/null +++ b/src/App/Pages/Accounts/LoginPasswordlessRequestPage.xaml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + diff --git a/src/App/Pages/Accounts/LoginPasswordlessRequestPage.xaml.cs b/src/App/Pages/Accounts/LoginPasswordlessRequestPage.xaml.cs new file mode 100644 index 000000000..383161c7c --- /dev/null +++ b/src/App/Pages/Accounts/LoginPasswordlessRequestPage.xaml.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.App.Models; +using Bit.App.Utilities; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public partial class LoginPasswordlessRequestPage : BaseContentPage + { + private LoginPasswordlessRequestViewModel _vm; + private readonly AppOptions _appOptions; + + public LoginPasswordlessRequestPage(string email, AppOptions appOptions = null) + { + InitializeComponent(); + _appOptions = appOptions; + _vm = BindingContext as LoginPasswordlessRequestViewModel; + _vm.Page = this; + _vm.Email = email; + _vm.StartTwoFactorAction = () => Device.BeginInvokeOnMainThread(async () => await StartTwoFactorAsync()); + _vm.LogInSuccessAction = () => Device.BeginInvokeOnMainThread(async () => await LogInSuccessAsync()); + _vm.UpdateTempPasswordAction = () => Device.BeginInvokeOnMainThread(async () => await UpdateTempPasswordAsync()); + _vm.CloseAction = () => { Navigation.PopModalAsync(); }; + + _vm.CreatePasswordlessLoginCommand.Execute(null); + } + + protected override void OnAppearing() + { + base.OnAppearing(); + _vm.StartCheckLoginRequestStatus(); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + _vm.StopCheckLoginRequestStatus(); + } + + private async Task StartTwoFactorAsync() + { + var page = new TwoFactorPage(false, _appOptions); + await Navigation.PushModalAsync(new NavigationPage(page)); + } + + private async Task LogInSuccessAsync() + { + if (AppHelpers.SetAlternateMainPage(_appOptions)) + { + return; + } + var previousPage = await AppHelpers.ClearPreviousPage(); + Application.Current.MainPage = new TabsPage(_appOptions, previousPage); + } + + private async Task UpdateTempPasswordAsync() + { + var page = new UpdateTempPasswordPage(); + await Navigation.PushModalAsync(new NavigationPage(page)); + } + } +} + diff --git a/src/App/Pages/Accounts/LoginPasswordlessRequestViewModel.cs b/src/App/Pages/Accounts/LoginPasswordlessRequestViewModel.cs new file mode 100644 index 000000000..b21437c7a --- /dev/null +++ b/src/App/Pages/Accounts/LoginPasswordlessRequestViewModel.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using Bit.App.Abstractions; +using Bit.App.Resources; +using Bit.App.Utilities; +using Bit.Core; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Models.Domain; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Xamarin.CommunityToolkit.ObjectModel; +using Xamarin.Essentials; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public class LoginPasswordlessRequestViewModel : CaptchaProtectedViewModel + { + private const int REQUEST_TIME_UPDATE_PERIOD_IN_SECONDS = 4; + + private IDeviceActionService _deviceActionService; + private IAuthService _authService; + private ISyncService _syncService; + private II18nService _i18nService; + private IStateService _stateService; + private IPlatformUtilsService _platformUtilsService; + private IEnvironmentService _environmentService; + private ILogger _logger; + + protected override II18nService i18nService => _i18nService; + protected override IEnvironmentService environmentService => _environmentService; + protected override IDeviceActionService deviceActionService => _deviceActionService; + protected override IPlatformUtilsService platformUtilsService => _platformUtilsService; + + private CancellationTokenSource _checkLoginRequestStatusCts; + private Task _checkLoginRequestStatusTask; + private string _fingerprintPhrase; + private string _email; + private string _requestId; + private string _requestAccessCode; + // Item1 publicKey, Item2 privateKey + private Tuple _requestKeyPair; + + public LoginPasswordlessRequestViewModel() + { + _deviceActionService = ServiceContainer.Resolve(); + _platformUtilsService = ServiceContainer.Resolve(); + _environmentService = ServiceContainer.Resolve(); + _authService = ServiceContainer.Resolve(); + _syncService = ServiceContainer.Resolve(); + _i18nService = ServiceContainer.Resolve(); + _stateService = ServiceContainer.Resolve(); + _logger = ServiceContainer.Resolve(); + + PageTitle = AppResources.LogInWithAnotherDevice; + + CreatePasswordlessLoginCommand = new AsyncCommand(CreatePasswordlessLoginAsync, + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); + + CloseCommand = new AsyncCommand(() => Device.InvokeOnMainThreadAsync(CloseAction), + onException: _logger.Exception, + allowsMultipleExecutions: false); + } + + public Action StartTwoFactorAction { get; set; } + public Action LogInSuccessAction { get; set; } + public Action UpdateTempPasswordAction { get; set; } + public Action CloseAction { get; set; } + + public ICommand CreatePasswordlessLoginCommand { get; } + public ICommand CloseCommand { get; } + + public string FingerprintPhrase + { + get => _fingerprintPhrase; + set => SetProperty(ref _fingerprintPhrase, value); + } + + public string Email + { + get => _email; + set => SetProperty(ref _email, value); + } + + public void StartCheckLoginRequestStatus() + { + try + { + _checkLoginRequestStatusCts?.Cancel(); + _checkLoginRequestStatusCts = new CancellationTokenSource(); + _checkLoginRequestStatusTask = new TimerTask(_logger, CheckLoginRequestStatus, _checkLoginRequestStatusCts).RunPeriodic(TimeSpan.FromSeconds(REQUEST_TIME_UPDATE_PERIOD_IN_SECONDS)); + } + catch (Exception ex) + { + _logger.Exception(ex); + } + } + + public void StopCheckLoginRequestStatus() + { + try + { + _checkLoginRequestStatusCts?.Cancel(); + _checkLoginRequestStatusCts?.Dispose(); + _checkLoginRequestStatusCts = null; + } + catch (Exception ex) + { + _logger.Exception(ex); + } + } + + private async Task CheckLoginRequestStatus() + { + if (string.IsNullOrEmpty(_requestId) || string.IsNullOrEmpty(_requestAccessCode)) + { + return; + } + + try + { + var response = await _authService.GetPasswordlessLoginResponseAsync(_requestId, _requestAccessCode); + + if (!response.RequestApproved) + { + return; + } + + StopCheckLoginRequestStatus(); + + var authResult = await _authService.LogInPasswordlessAsync(Email, _requestAccessCode, _requestId, _requestKeyPair.Item2, response.Key, response.MasterPasswordHash); + await AppHelpers.ResetInvalidUnlockAttemptsAsync(); + + if (await HandleCaptchaAsync(authResult.CaptchaSiteKey, authResult.CaptchaNeeded, CheckLoginRequestStatus)) + { + return; + } + + if (authResult.TwoFactor) + { + StartTwoFactorAction?.Invoke(); + } + else if (authResult.ForcePasswordReset) + { + UpdateTempPasswordAction?.Invoke(); + } + else + { + _syncService.FullSyncAsync(true).FireAndForget(); + LogInSuccessAction?.Invoke(); + } + } + catch (Exception ex) + { + StartCheckLoginRequestStatus(); + HandleException(ex); + } + } + + private async Task CreatePasswordlessLoginAsync() + { + await Device.InvokeOnMainThreadAsync(() => _deviceActionService.ShowLoadingAsync(AppResources.Loading)); + + var response = await _authService.PasswordlessCreateLoginRequestAsync(_email); + if (response != null) + { + FingerprintPhrase = response.RequestFingerprint; + _requestId = response.Id; + _requestAccessCode = response.RequestAccessCode; + _requestKeyPair = response.RequestKeyPair; + } + + await _deviceActionService.HideLoadingAsync(); + } + + private void HandleException(Exception ex) + { + Xamarin.Essentials.MainThread.InvokeOnMainThreadAsync(async () => + { + await _deviceActionService.HideLoadingAsync(); + await _platformUtilsService.ShowDialogAsync(AppResources.GenericErrorMessage); + }).FireAndForget(); + _logger.Exception(ex); + } + } +} + diff --git a/src/App/Pages/CaptchaProtectedViewModel.cs b/src/App/Pages/CaptchaProtectedViewModel.cs index 042d1423c..2f9969080 100644 --- a/src/App/Pages/CaptchaProtectedViewModel.cs +++ b/src/App/Pages/CaptchaProtectedViewModel.cs @@ -16,6 +16,22 @@ namespace Bit.App.Pages protected abstract IPlatformUtilsService platformUtilsService { get; } protected string _captchaToken = null; + protected async Task HandleCaptchaAsync(string captchaSiteKey, bool needsCaptcha, Func onSuccess) + { + if (!needsCaptcha) + { + _captchaToken = null; + return false; + } + + if (await HandleCaptchaAsync(captchaSiteKey)) + { + await onSuccess(); + _captchaToken = null; + } + return true; + } + protected async Task HandleCaptchaAsync(string CaptchaSiteKey) { var callbackUri = "bitwarden://captcha-callback"; diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index a98953e55..7da43e892 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -481,6 +481,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to A notification has been sent to your device.. + /// + public static string ANotificationHasBeenSentToYourDevice { + get { + return ResourceManager.GetString("ANotificationHasBeenSentToYourDevice", resourceCulture); + } + } + /// /// Looks up a localized string similar to API access token. /// @@ -3480,6 +3489,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Log in initiated. + /// + public static string LogInInitiated { + get { + return ResourceManager.GetString("LogInInitiated", resourceCulture); + } + } + /// /// Looks up a localized string similar to Login. /// @@ -3966,6 +3984,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Need another option?. + /// + public static string NeedAnotherOption { + get { + return ResourceManager.GetString("NeedAnotherOption", resourceCulture); + } + } + /// /// Looks up a localized string similar to Never. /// @@ -4705,6 +4732,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device.. + /// + public static string PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice { + get { + return ResourceManager.GetString("PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice", resourceCulture); + } + } + /// /// Looks up a localized string similar to Plus addressed email. /// @@ -5003,6 +5039,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Resend notification. + /// + public static string ResendNotification { + get { + return ResourceManager.GetString("ResendNotification", resourceCulture); + } + } + /// /// Looks up a localized string similar to This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password.. /// @@ -6542,6 +6587,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to View all log in options. + /// + public static string ViewAllLoginOptions { + get { + return ResourceManager.GetString("ViewAllLoginOptions", resourceCulture); + } + } + /// /// Looks up a localized string similar to View item. /// diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index 03f1c7aba..610ad0597 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -2491,4 +2491,22 @@ Do you want to switch to this account? Log In with another device + + Log in initiated + + + A notification has been sent to your device. + + + Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device. + + + Resend notification + + + Need another option? + + + View all log in options + diff --git a/src/Core/Abstractions/IApiService.cs b/src/Core/Abstractions/IApiService.cs index 208d17c0c..5c85e41ea 100644 --- a/src/Core/Abstractions/IApiService.cs +++ b/src/Core/Abstractions/IApiService.cs @@ -84,7 +84,9 @@ namespace Bit.Core.Abstractions Task PutSendRemovePasswordAsync(string id); Task DeleteSendAsync(string id); Task GetAuthRequestAsync(string id); + Task GetAuthResponseAsync(string id, string accessCode); Task PutAuthRequestAsync(string id, string key, string masterPasswordHash, string deviceIdentifier, bool requestApproved); + Task PostCreateRequestAsync(PasswordlessCreateLoginRequest passwordlessCreateLoginRequest); Task GetUsernameFromAsync(ForwardedEmailServiceType service, UsernameGeneratorConfig config); Task GetKnownDeviceAsync(string email, string deviceIdentifier); } diff --git a/src/Core/Abstractions/IAuthService.cs b/src/Core/Abstractions/IAuthService.cs index e151daabd..bebd290d5 100644 --- a/src/Core/Abstractions/IAuthService.cs +++ b/src/Core/Abstractions/IAuthService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Bit.Core.Enums; using Bit.Core.Models.Domain; +using Bit.Core.Models.Request; using Bit.Core.Models.Response; namespace Bit.Core.Abstractions @@ -26,9 +27,12 @@ namespace Bit.Core.Abstractions Task LogInSsoAsync(string code, string codeVerifier, string redirectUrl, string orgId); Task LogInCompleteAsync(string email, string masterPassword, TwoFactorProviderType twoFactorProvider, string twoFactorToken, bool? remember = null); Task LogInTwoFactorAsync(TwoFactorProviderType twoFactorProvider, string twoFactorToken, string captchaToken, bool? remember = null); + Task LogInPasswordlessAsync(string email, string accessCode, string authRequestId, byte[] decryptionKey, string userKeyCiphered, string localHashedPasswordCiphered); Task GetPasswordlessLoginRequestByIdAsync(string id); + Task GetPasswordlessLoginResponseAsync(string id, string accessCode); Task PasswordlessLoginAsync(string id, string pubKey, bool requestApproved); + Task PasswordlessCreateLoginRequestAsync(string email); void LogOut(Action callback); void Init(); diff --git a/src/Core/Abstractions/ICryptoService.cs b/src/Core/Abstractions/ICryptoService.cs index c06b5b272..b628f3ac1 100644 --- a/src/Core/Abstractions/ICryptoService.cs +++ b/src/Core/Abstractions/ICryptoService.cs @@ -46,6 +46,7 @@ namespace Bit.Core.Abstractions Task RandomNumberAsync(int min, int max); Task> RemakeEncKeyAsync(SymmetricCryptoKey key); Task RsaEncryptAsync(byte[] data, byte[] publicKey = null); + Task RsaDecryptAsync(string encValue, byte[] privateKey = null); Task SetEncKeyAsync(string encKey); Task SetEncPrivateKeyAsync(string encPrivateKey); Task SetKeyAsync(SymmetricCryptoKey key); diff --git a/src/Core/Models/Request/PasswordlessCreateLoginRequest.cs b/src/Core/Models/Request/PasswordlessCreateLoginRequest.cs new file mode 100644 index 000000000..aeaff5f1f --- /dev/null +++ b/src/Core/Models/Request/PasswordlessCreateLoginRequest.cs @@ -0,0 +1,34 @@ +using System; +namespace Bit.Core.Models.Request +{ + public class PasswordlessCreateLoginRequest + { + public PasswordlessCreateLoginRequest(string email, string publicKey, string deviceIdentifier, string accessCode, AuthRequestType? type, string fingerprintPhrase) + { + Email = email ?? throw new ArgumentNullException(nameof(email)); + PublicKey = publicKey ?? throw new ArgumentNullException(nameof(publicKey)); + DeviceIdentifier = deviceIdentifier ?? throw new ArgumentNullException(nameof(deviceIdentifier)); + AccessCode = accessCode ?? throw new ArgumentNullException(nameof(accessCode)); + Type = type; + FingerprintPhrase = fingerprintPhrase ?? throw new ArgumentNullException(nameof(fingerprintPhrase)); + } + + public string Email { get; set; } + + public string PublicKey { get; set; } + + public string DeviceIdentifier { get; set; } + + public string AccessCode { get; set; } + + public AuthRequestType? Type { get; set; } + + public string FingerprintPhrase { get; set; } + } + + public enum AuthRequestType : byte + { + AuthenticateAndUnlock = 0, + Unlock = 1 + } +} diff --git a/src/Core/Models/Request/TokenRequest.cs b/src/Core/Models/Request/TokenRequest.cs index fe299e455..75d14fdb9 100644 --- a/src/Core/Models/Request/TokenRequest.cs +++ b/src/Core/Models/Request/TokenRequest.cs @@ -15,13 +15,14 @@ namespace Bit.Core.Models.Request public string CodeVerifier { get; set; } public string RedirectUri { get; set; } public string Token { get; set; } + public string AuthRequestId { get; set; } public TwoFactorProviderType? Provider { get; set; } public bool? Remember { get; set; } public string CaptchaToken { get; set; } public DeviceRequest Device { get; set; } public TokenRequest(string[] credentials, string[] codes, TwoFactorProviderType? provider, string token, - bool? remember, string captchaToken, DeviceRequest device = null) + bool? remember, string captchaToken, DeviceRequest device = null, string authRequestId = null) { if (credentials != null && credentials.Length > 1) { @@ -39,6 +40,7 @@ namespace Bit.Core.Models.Request Remember = remember; Device = device; CaptchaToken = captchaToken; + AuthRequestId = authRequestId; } public Dictionary ToIdentityToken(string clientId) @@ -67,6 +69,11 @@ namespace Bit.Core.Models.Request throw new Exception("must provide credentials or codes"); } + if (AuthRequestId != null) + { + obj.Add("authRequest", AuthRequestId); + } + if (Device != null) { obj.Add("deviceType", ((int)Device.Type).ToString()); diff --git a/src/Core/Models/Response/PasswordlessLoginResponse.cs b/src/Core/Models/Response/PasswordlessLoginResponse.cs index 20113de8e..ca830e75f 100644 --- a/src/Core/Models/Response/PasswordlessLoginResponse.cs +++ b/src/Core/Models/Response/PasswordlessLoginResponse.cs @@ -15,5 +15,7 @@ namespace Bit.Core.Models.Response public DateTime CreationDate { get; set; } public bool RequestApproved { get; set; } public string Origin { get; set; } + public string RequestAccessCode { get; set; } + public Tuple RequestKeyPair { get; set; } } } diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index 256135310..938cbecfa 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -541,6 +541,16 @@ namespace Bit.Core.Services return SendAsync(HttpMethod.Get, $"/auth-requests/{id}", null, true, true); } + public Task GetAuthResponseAsync(string id, string accessCode) + { + return SendAsync(HttpMethod.Get, $"/auth-requests/{id}/response?code={accessCode}", null, false, true); + } + + public Task PostCreateRequestAsync(PasswordlessCreateLoginRequest passwordlessCreateLoginRequest) + { + return SendAsync(HttpMethod.Post, $"/auth-requests", passwordlessCreateLoginRequest, false, true); + } + public Task PutAuthRequestAsync(string id, string encKey, string encMasterPasswordHash, string deviceIdentifier, bool requestApproved) { var request = new PasswordlessLoginRequest(encKey, encMasterPasswordHash, deviceIdentifier, requestApproved); diff --git a/src/Core/Services/AuthService.cs b/src/Core/Services/AuthService.cs index f85473397..db09798db 100644 --- a/src/Core/Services/AuthService.cs +++ b/src/Core/Services/AuthService.cs @@ -24,8 +24,8 @@ namespace Bit.Core.Services private readonly IPlatformUtilsService _platformUtilsService; private readonly IMessagingService _messagingService; private readonly IKeyConnectorService _keyConnectorService; + private readonly IPasswordGenerationService _passwordGenerationService; private readonly bool _setCryptoKeys; - private SymmetricCryptoKey _key; public AuthService( @@ -40,6 +40,7 @@ namespace Bit.Core.Services IMessagingService messagingService, IVaultTimeoutService vaultTimeoutService, IKeyConnectorService keyConnectorService, + IPasswordGenerationService passwordGenerationService, bool setCryptoKeys = true) { _cryptoService = cryptoService; @@ -52,6 +53,7 @@ namespace Bit.Core.Services _platformUtilsService = platformUtilsService; _messagingService = messagingService; _keyConnectorService = keyConnectorService; + _passwordGenerationService = passwordGenerationService; _setCryptoKeys = setCryptoKeys; TwoFactorProviders = new Dictionary(); @@ -137,6 +139,14 @@ namespace Bit.Core.Services null, captchaToken); } + public async Task LogInPasswordlessAsync(string email, string accessCode, string authRequestId, byte[] decryptionKey, string userKeyCiphered, string localHashedPasswordCiphered) + { + var decKey = await _cryptoService.RsaDecryptAsync(userKeyCiphered, decryptionKey); + var decPasswordHash = await _cryptoService.RsaDecryptAsync(localHashedPasswordCiphered, decryptionKey); + return await LogInHelperAsync(email, accessCode, Encoding.UTF8.GetString(decPasswordHash), null, null, null, new SymmetricCryptoKey(decKey), null, null, + null, null, authRequestId: authRequestId); + } + public async Task LogInSsoAsync(string code, string codeVerifier, string redirectUrl, string orgId) { SelectedTwoFactorProviderType = null; @@ -286,7 +296,7 @@ namespace Bit.Core.Services private async Task LogInHelperAsync(string email, string hashedPassword, string localHashedPassword, string code, string codeVerifier, string redirectUrl, SymmetricCryptoKey key, TwoFactorProviderType? twoFactorProvider = null, string twoFactorToken = null, bool? remember = null, - string captchaToken = null, string orgId = null) + string captchaToken = null, string orgId = null, string authRequestId = null) { var storedTwoFactorToken = await _tokenService.GetTwoFactorTokenAsync(email); var appId = await _appIdService.GetAppIdAsync(); @@ -322,6 +332,10 @@ namespace Bit.Core.Services request = new TokenRequest(emailPassword, codeCodeVerifier, TwoFactorProviderType.Remember, storedTwoFactorToken, false, captchaToken, deviceRequest); } + else if (authRequestId != null) + { + request = new TokenRequest(emailPassword, null, null, null, false, null, deviceRequest, authRequestId); + } else { request = new TokenRequest(emailPassword, codeCodeVerifier, null, null, false, captchaToken, deviceRequest); @@ -476,6 +490,11 @@ namespace Bit.Core.Services return await _apiService.GetAuthRequestAsync(id); } + public async Task GetPasswordlessLoginResponseAsync(string id, string accessCode) + { + return await _apiService.GetAuthResponseAsync(id, accessCode); + } + public async Task PasswordlessLoginAsync(string id, string pubKey, bool requestApproved) { var publicKey = CoreHelpers.Base64UrlDecode(pubKey); @@ -485,5 +504,25 @@ namespace Bit.Core.Services var deviceId = await _appIdService.GetAppIdAsync(); return await _apiService.PutAuthRequestAsync(id, encryptedKey.EncryptedString, encryptedMasterPassword.EncryptedString, deviceId, requestApproved); } + + public async Task PasswordlessCreateLoginRequestAsync(string email) + { + var deviceId = await _appIdService.GetAppIdAsync(); + var keyPair = await _cryptoFunctionService.RsaGenerateKeyPairAsync(2048); + var generatedFingerprintPhrase = await _cryptoService.GetFingerprintAsync(email, keyPair.Item1); + var fingerprintPhrase = string.Join("-", generatedFingerprintPhrase); + var publicB64 = Convert.ToBase64String(keyPair.Item1); + var accessCode = await _passwordGenerationService.GeneratePasswordAsync(new PasswordGenerationOptions(true) { Length = 25 }); + var passwordlessCreateLoginRequest = new PasswordlessCreateLoginRequest(email, publicB64, deviceId, accessCode, AuthRequestType.AuthenticateAndUnlock, fingerprintPhrase); + var response = await _apiService.PostCreateRequestAsync(passwordlessCreateLoginRequest); + + if (response != null) + { + response.RequestKeyPair = keyPair; + response.RequestAccessCode = accessCode; + } + + return response; + } } } diff --git a/src/Core/Services/CryptoService.cs b/src/Core/Services/CryptoService.cs index 5cd5312eb..96977ca5a 100644 --- a/src/Core/Services/CryptoService.cs +++ b/src/Core/Services/CryptoService.cs @@ -723,7 +723,7 @@ namespace Bit.Core.Services return await _cryptoFunctionService.AesDecryptAsync(data, iv, theKey.EncKey); } - private async Task RsaDecryptAsync(string encValue) + public async Task RsaDecryptAsync(string encValue, byte[] privateKey = null) { var headerPieces = encValue.Split('.'); EncryptionType? encType = null; @@ -750,7 +750,12 @@ namespace Bit.Core.Services } var data = Convert.FromBase64String(encPieces[0]); - var privateKey = await GetPrivateKeyAsync(); + + if (privateKey is null) + { + privateKey = await GetPrivateKeyAsync(); + } + if (privateKey == null) { throw new Exception("No private key."); diff --git a/src/Core/Utilities/ServiceContainer.cs b/src/Core/Utilities/ServiceContainer.cs index 4015d960e..8386facc2 100644 --- a/src/Core/Utilities/ServiceContainer.cs +++ b/src/Core/Utilities/ServiceContainer.cs @@ -77,7 +77,7 @@ namespace Bit.Core.Utilities var totpService = new TotpService(cryptoFunctionService); var authService = new AuthService(cryptoService, cryptoFunctionService, apiService, stateService, tokenService, appIdService, i18nService, platformUtilsService, messagingService, vaultTimeoutService, - keyConnectorService); + keyConnectorService, passwordGenerationService); var exportService = new ExportService(folderService, cipherService, cryptoService); var auditService = new AuditService(cryptoFunctionService, apiService); var environmentService = new EnvironmentService(apiService, stateService); diff --git a/src/iOS.Autofill/CredentialProviderViewController.cs b/src/iOS.Autofill/CredentialProviderViewController.cs index 711fb0652..8287c8fb1 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.cs @@ -497,6 +497,7 @@ namespace Bit.iOS.Autofill vm.StartTwoFactorAction = () => DismissViewController(false, () => LaunchTwoFactorFlow(false)); vm.UpdateTempPasswordAction = () => DismissViewController(false, () => LaunchUpdateTempPasswordFlow()); vm.StartSsoLoginAction = () => DismissViewController(false, () => LaunchLoginSsoFlow()); + vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(email)); vm.LogInSuccessAction = () => DismissLockAndContinue(); vm.CloseAction = () => DismissViewController(false, () => LaunchHomePage(checkRememberedEmail: false)); } @@ -509,6 +510,29 @@ namespace Bit.iOS.Autofill LogoutIfAuthed(); } + private void LaunchLoginWithDevice(string email = null) + { + var appOptions = new AppOptions { IosExtension = true }; + var app = new App.App(appOptions); + var loginWithDevicePage = new LoginPasswordlessRequestPage(email, appOptions); + ThemeManager.SetTheme(app.Resources); + ThemeManager.ApplyResourcesTo(loginWithDevicePage); + if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm) + { + vm.StartTwoFactorAction = () => DismissViewController(false, () => LaunchTwoFactorFlow(false)); + vm.UpdateTempPasswordAction = () => DismissViewController(false, () => LaunchUpdateTempPasswordFlow()); + vm.LogInSuccessAction = () => DismissLockAndContinue(); + vm.CloseAction = () => DismissViewController(false, () => LaunchHomePage()); + } + + var navigationPage = new NavigationPage(loginWithDevicePage); + var loginController = navigationPage.CreateViewController(); + loginController.ModalPresentationStyle = UIModalPresentationStyle.FullScreen; + PresentViewController(loginController, true, null); + + LogoutIfAuthed(); + } + private void LaunchLoginSsoFlow() { var loginPage = new LoginSsoPage(); diff --git a/src/iOS.Extension/LoadingViewController.cs b/src/iOS.Extension/LoadingViewController.cs index b60837779..6d001ce0b 100644 --- a/src/iOS.Extension/LoadingViewController.cs +++ b/src/iOS.Extension/LoadingViewController.cs @@ -518,6 +518,7 @@ namespace Bit.iOS.Extension vm.StartTwoFactorAction = () => DismissViewController(false, () => LaunchTwoFactorFlow(false)); vm.UpdateTempPasswordAction = () => DismissViewController(false, () => LaunchUpdateTempPasswordFlow()); vm.StartSsoLoginAction = () => DismissViewController(false, () => LaunchLoginSsoFlow()); + vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(email)); vm.LogInSuccessAction = () => DismissLockAndContinue(); vm.CloseAction = () => DismissViewController(false, () => LaunchHomePage(checkRememberedEmail: false)); } @@ -530,6 +531,29 @@ namespace Bit.iOS.Extension LogoutIfAuthed(); } + private void LaunchLoginWithDevice(string email = null) + { + var appOptions = new AppOptions { IosExtension = true }; + var app = new App.App(appOptions); + var loginWithDevicePage = new LoginPasswordlessRequestPage(email, appOptions); + ThemeManager.SetTheme(app.Resources); + ThemeManager.ApplyResourcesTo(loginWithDevicePage); + if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm) + { + vm.StartTwoFactorAction = () => DismissViewController(false, () => LaunchTwoFactorFlow(false)); + vm.UpdateTempPasswordAction = () => DismissViewController(false, () => LaunchUpdateTempPasswordFlow()); + vm.LogInSuccessAction = () => DismissLockAndContinue(); + vm.CloseAction = () => DismissViewController(false, () => LaunchHomePage()); + } + + var navigationPage = new NavigationPage(loginWithDevicePage); + var loginController = navigationPage.CreateViewController(); + loginController.ModalPresentationStyle = UIModalPresentationStyle.FullScreen; + PresentViewController(loginController, true, null); + + LogoutIfAuthed(); + } + private void LaunchLoginSsoFlow() { var loginPage = new LoginSsoPage(); diff --git a/src/iOS.ShareExtension/LoadingViewController.cs b/src/iOS.ShareExtension/LoadingViewController.cs index 93213cff2..4d9227e56 100644 --- a/src/iOS.ShareExtension/LoadingViewController.cs +++ b/src/iOS.ShareExtension/LoadingViewController.cs @@ -339,10 +339,8 @@ namespace Bit.iOS.ShareExtension vm.StartTwoFactorAction = () => DismissAndLaunch(() => LaunchTwoFactorFlow(false)); vm.UpdateTempPasswordAction = () => DismissAndLaunch(() => LaunchUpdateTempPasswordFlow()); vm.StartSsoLoginAction = () => DismissAndLaunch(() => LaunchLoginSsoFlow()); - vm.LogInSuccessAction = () => - { - DismissLockAndContinue(); - }; + vm.LogInWithDeviceAction = () => DismissAndLaunch(() => LaunchLoginWithDevice(email)); + vm.LogInSuccessAction = () => { DismissLockAndContinue(); }; vm.CloseAction = () => DismissAndLaunch(() => LaunchHomePage(checkRememberedEmail: false)); } NavigateToPage(loginPage); @@ -350,6 +348,22 @@ namespace Bit.iOS.ShareExtension LogoutIfAuthed(); } + private void LaunchLoginWithDevice(string email = null) + { + var loginWithDevicePage = new LoginPasswordlessRequestPage(email, _appOptions.Value); + SetupAppAndApplyResources(loginWithDevicePage); + if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm) + { + vm.StartTwoFactorAction = () => DismissAndLaunch(() => LaunchTwoFactorFlow(false)); + vm.UpdateTempPasswordAction = () => DismissAndLaunch(() => LaunchUpdateTempPasswordFlow()); + vm.LogInSuccessAction = () => { DismissLockAndContinue(); }; + vm.CloseAction = () => DismissAndLaunch(() => LaunchHomePage()); + } + NavigateToPage(loginWithDevicePage); + + LogoutIfAuthed(); + } + private void LaunchLoginSsoFlow() { var loginPage = new LoginSsoPage();