1
0
mirror of https://github.com/bitwarden/mobile.git synced 2024-11-27 12:26:31 +01:00

Passwordless feature branch PR (#2100)

* [SG-471] Passwordless device login screen (#2017)

* [SSG-471] Added UI for the device login request response.

* [SG-471] Added text resources and arguments to Page.

* [SG-471] Added properties to speed up page bindings

* [SG-471] Added mock services. Added Accept/reject command binding, navigation and toast messages.

* [SG-471] fixed code styling with dotnet-format

* [SG-471] Fixed back button placement. PR fixes.

* [SG-471] Added new Origin parameter to the page.

* [SG-471] PR Fixes

* [SG-471] PR fixes

* [SG-471] PR Fix: added FireAndForget.

* [SG-471] Moved fire and forget to run on ui thread task.

* [SG-381] Passwordless - Add setting to Mobile (#2037)

* [SG-381] Added settings option to approve passwordless login request. If user has notifications disabled, prompt to go to settings and enable them.

* [SG-381] Update settings pop up texts.

* [SG-381] Added new method to get notifications state on device settings. Added userId to property saved on device to differentiate value between users.

* [SG-381] Added text for the popup on selection.

* [SG-381] PR Fixes

* [SG-408] Implement passwordless api methods (#2055)

* [SG-408] Update notification model.

* [SG-408] removed duplicated resource

* [SG-408] Added implementation to Api Service of new passwordless methods.

* removed qa endpoints

* [SG-408] Changed auth methods implementation, added method call to viewmodel.

* [SG-408] ran code format

* [SG-408] PR fixes

* [SG-472] Add configuration for new notification type (#2056)

* [SG-472] Added methods to present local notification to the user. Configured new notification type for passwordless logins

* [SG-472] Updated code to new api service changes.

* [SG-472] ran dotnet format

* [SG-472] PR Fixes.

* [SG-472] PR Fixes

* [SG-169] End-to-end testing refactor. (#2073)

* [SG-169] Passwordless demo change requests (#2079)

* [SG-169] End-to-end testing refactor.

* [SG-169] Fixed labels. Changed color of Fingerprint phrase. Waited for app to be in foreground to launch passwordless modal to fix Android issues.

* [SG-169] Anchored buttons to the bottom of the screen.

* [SG-169] Changed device type from enum to string.

* [SG-169] PR fixes

* [SG-169] PR fixes

* [SG-169] Added comment on static variable
This commit is contained in:
André Bispo 2022-09-26 18:27:57 +01:00 committed by GitHub
parent 2f4cd36595
commit f9a32e4abc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 852 additions and 30 deletions

View File

@ -12,6 +12,7 @@ using Android.Runtime;
using Android.Views;
using Bit.App.Abstractions;
using Bit.App.Models;
using Bit.App.Resources;
using Bit.App.Utilities;
using Bit.Core;
using Bit.Core.Abstractions;
@ -86,6 +87,7 @@ namespace Bit.Droid
Xamarin.Essentials.Platform.Init(this, savedInstanceState);
Xamarin.Forms.Forms.Init(this, savedInstanceState);
_appOptions = GetOptions();
CreateNotificationChannel();
LoadApplication(new App.App(_appOptions));
DisableAndroidFontScale();
@ -407,6 +409,25 @@ namespace Bit.Droid
await _eventService.UploadEventsAsync();
}
private void CreateNotificationChannel()
{
#if !FDROID
if (Build.VERSION.SdkInt < BuildVersionCodes.O)
{
// Notification channels are new in API 26 (and not a part of the
// support library). There is no need to create a notification
// channel on older versions of Android.
return;
}
var channel = new NotificationChannel(Constants.AndroidNotificationChannelId, AppResources.AllNotifications, NotificationImportance.Default);
if(GetSystemService(NotificationService) is NotificationManager notificationManager)
{
notificationManager.CreateNotificationChannel(channel);
}
#endif
}
private void DisableAndroidFontScale()
{
try

View File

@ -1,7 +1,9 @@
#if !FDROID
using System;
using Android.App;
using Bit.App.Abstractions;
using Bit.Core.Abstractions;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Firebase.Messaging;
using Newtonsoft.Json;
@ -16,14 +18,22 @@ namespace Bit.Droid.Push
{
public async override void OnNewToken(string token)
{
try {
var stateService = ServiceContainer.Resolve<IStateService>("stateService");
var pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>("pushNotificationService");
await stateService.SetPushRegisteredTokenAsync(token);
await pushNotificationService.RegisterAsync();
}
catch (Exception ex)
{
Logger.Instance.Exception(ex);
}
}
public async override void OnMessageReceived(RemoteMessage message)
{
try
{
if (message?.Data == null)
{
@ -34,16 +44,15 @@ namespace Bit.Droid.Push
{
return;
}
try
{
var obj = JObject.Parse(data);
var listener = ServiceContainer.Resolve<IPushNotificationListenerService>(
"pushNotificationListenerService");
await listener.OnMessageAsync(obj, Device.Android);
}
catch (JsonReaderException ex)
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(ex.ToString());
Logger.Instance.Exception(ex);
}
}
}

View File

@ -1,8 +1,11 @@
#if !FDROID
using System;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using AndroidX.Core.App;
using Bit.App.Abstractions;
using Bit.Core;
using Bit.Core.Abstractions;
using Xamarin.Forms;
@ -23,6 +26,11 @@ namespace Bit.Droid.Services
public bool IsRegisteredForPush => NotificationManagerCompat.From(Android.App.Application.Context)?.AreNotificationsEnabled() ?? false;
public Task<bool> AreNotificationsSettingsEnabledAsync()
{
return Task.FromResult(IsRegisteredForPush);
}
public async Task<string> GetTokenAsync()
{
return await _stateService.GetPushCurrentTokenAsync();
@ -47,6 +55,36 @@ namespace Bit.Droid.Services
// Do we ever need to unregister?
return Task.FromResult(0);
}
public void DismissLocalNotification(string notificationId)
{
if (int.TryParse(notificationId, out int intNotificationId))
{
var notificationManager = NotificationManagerCompat.From(Android.App.Application.Context);
notificationManager.Cancel(intNotificationId);
}
}
public void SendLocalNotification(string title, string message, string notificationId)
{
if (string.IsNullOrEmpty(notificationId))
{
throw new ArgumentNullException("notificationId cannot be null or empty.");
}
var context = Android.App.Application.Context;
var intent = new Intent(context, typeof(MainActivity));
var pendingIntent = PendingIntent.GetActivity(context, 20220801, intent, PendingIntentFlags.UpdateCurrent);
var builder = new NotificationCompat.Builder(context, Constants.AndroidNotificationChannelId)
.SetContentIntent(pendingIntent)
.SetContentTitle(title)
.SetContentText(message)
.SetSmallIcon(Resource.Mipmap.ic_launcher)
.SetAutoCancel(true);
var notificationManager = NotificationManagerCompat.From(context);
notificationManager.Notify(int.Parse(notificationId), builder.Build());
}
}
}
#endif

View File

@ -964,5 +964,14 @@ namespace Bit.Droid.Services
}
activity.RunOnUiThread(() => activity.Window.AddFlags(WindowManagerFlags.Secure));
}
public void OpenAppSettings()
{
var intent = new Intent(Android.Provider.Settings.ActionApplicationDetailsSettings);
intent.AddFlags(ActivityFlags.NewTask);
var uri = Android.Net.Uri.FromParts("package", Application.Context.PackageName, null);
intent.SetData(uri);
Application.Context.StartActivity(intent);
}
}
}

View File

@ -49,5 +49,6 @@ namespace Bit.App.Abstractions
float GetSystemFontSizeScale();
Task OnAccountSwitchCompleteAsync();
Task SetScreenCaptureAllowedAsync();
void OpenAppSettings();
}
}

View File

@ -1,12 +1,16 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Bit.App.Abstractions
{
public interface IPushNotificationService
{
bool IsRegisteredForPush { get; }
Task<bool> AreNotificationsSettingsEnabledAsync();
Task<string> GetTokenAsync();
Task RegisterAsync();
Task UnregisterAsync();
void SendLocalNotification(string title, string message, string notificationId);
void DismissLocalNotification(string notificationId);
}
}

View File

@ -123,6 +123,9 @@
<SubType>Code</SubType>
</Compile>
<Compile Remove="Pages\Accounts\AccountsPopupPage.xaml.cs" />
<Compile Update="Pages\Accounts\LoginPasswordlessPage.xaml.cs">
<DependentUpon>LoginPasswordlessPage.xaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>

View File

@ -7,6 +7,7 @@ using Bit.App.Resources;
using Bit.App.Services;
using Bit.App.Utilities;
using Bit.App.Utilities.AccountManagement;
using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@ -25,13 +26,13 @@ namespace Bit.App
private readonly IStateService _stateService;
private readonly IVaultTimeoutService _vaultTimeoutService;
private readonly ISyncService _syncService;
private readonly IPlatformUtilsService _platformUtilsService;
private readonly IAuthService _authService;
private readonly IStorageService _secureStorageService;
private readonly IDeviceActionService _deviceActionService;
private readonly IAccountsManager _accountsManager;
private readonly IPushNotificationService _pushNotificationService;
private static bool _isResumed;
// this variable is static because the app is launching new activities on notification click, creating new instances of App.
private static bool _pendingCheckPasswordlessLoginRequests;
public App(AppOptions appOptions)
{
@ -47,10 +48,9 @@ namespace Bit.App
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
_syncService = ServiceContainer.Resolve<ISyncService>("syncService");
_authService = ServiceContainer.Resolve<IAuthService>("authService");
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
_secureStorageService = ServiceContainer.Resolve<IStorageService>("secureStorageService");
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
_accountsManager = ServiceContainer.Resolve<IAccountsManager>("accountsManager");
_pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>();
_accountsManager.Init(() => Options, this);
@ -140,6 +140,10 @@ namespace Bit.App
new NavigationPage(new RemoveMasterPasswordPage()));
});
}
else if (message.Command == "passwordlessLoginRequest" || message.Command == "unlocked")
{
CheckPasswordlessLoginRequestsAsync().FireAndForget();
}
}
catch (Exception ex)
{
@ -148,11 +152,52 @@ namespace Bit.App
});
}
private async Task CheckPasswordlessLoginRequestsAsync()
{
if (!_isResumed)
{
_pendingCheckPasswordlessLoginRequests = true;
return;
}
_pendingCheckPasswordlessLoginRequests = false;
if (await _vaultTimeoutService.IsLockedAsync())
{
return;
}
var notification = await _stateService.GetPasswordlessLoginNotificationAsync();
if (notification == null)
{
return;
}
// Delay to wait for the vault page to appear
await Task.Delay(2000);
var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(notification.Id);
var page = new LoginPasswordlessPage(new LoginPasswordlessDetails()
{
PubKey = loginRequestData.PublicKey,
Id = loginRequestData.Id,
IpAddress = loginRequestData.RequestIpAddress,
Email = await _stateService.GetEmailAsync(),
FingerprintPhrase = loginRequestData.RequestFingerprint,
RequestDate = loginRequestData.CreationDate,
DeviceType = loginRequestData.RequestDeviceType,
Origin = loginRequestData.Origin,
});
await _stateService.SetPasswordlessLoginNotificationAsync(null);
_pushNotificationService.DismissLocalNotification(Constants.PasswordlessNotificationId);
await Device.InvokeOnMainThreadAsync(async () => await Application.Current.MainPage.Navigation.PushModalAsync(new NavigationPage(page)));
}
public AppOptions Options { get; private set; }
protected async override void OnStart()
{
System.Diagnostics.Debug.WriteLine("XF App: OnStart");
_isResumed = true;
await ClearCacheIfNeededAsync();
Prime();
if (string.IsNullOrWhiteSpace(Options.Uri))
@ -164,6 +209,10 @@ namespace Bit.App
SyncIfNeeded();
}
}
if (_pendingCheckPasswordlessLoginRequests)
{
CheckPasswordlessLoginRequestsAsync().FireAndForget();
}
if (Device.RuntimePlatform == Device.Android)
{
await _vaultTimeoutService.CheckVaultTimeoutAsync();
@ -196,6 +245,10 @@ namespace Bit.App
{
System.Diagnostics.Debug.WriteLine("XF App: OnResume");
_isResumed = true;
if (_pendingCheckPasswordlessLoginRequests)
{
CheckPasswordlessLoginRequestsAsync().FireAndForget();
}
if (Device.RuntimePlatform == Device.Android)
{
ResumedAsync().FireAndForget();

View File

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?>
<pages:BaseContentPage
xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.Pages.LoginPasswordlessPage"
xmlns:pages="clr-namespace:Bit.App.Pages"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:u="clr-namespace:Bit.App.Utilities"
x:DataType="pages:LoginPasswordlessViewModel"
Title="{Binding PageTitle}">
<ContentPage.BindingContext>
<pages:LoginPasswordlessViewModel />
</ContentPage.BindingContext>
<ContentPage.Resources>
<ResourceDictionary>
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1"
x:Name="_closeItem" x:Key="closeItem" />
</ResourceDictionary>
</ContentPage.Resources>
<StackLayout
Padding="7, 0, 7, 20">
<ScrollView
VerticalOptions="FillAndExpand">
<StackLayout>
<Label
Text="{u:I18n AreYouTryingToLogIn}"
FontSize="Title"
FontAttributes="Bold"
Margin="0,14,0,21"/>
<Label
Text="{Binding LogInAttemptByLabel}"
FontSize="Small"
Margin="0,0,0,24"/>
<Label
Text="{u:I18n FingerprintPhrase}"
FontSize="Small"
FontAttributes="Bold"/>
<controls:MonoLabel
FormattedText="{Binding LoginRequest.FingerprintPhrase}"
FontSize="Medium"
TextColor="{DynamicResource FingerprintPhrase}"
Margin="0,0,0,27"/>
<Label
Text="{u:I18n DeviceType}"
FontSize="Small"
FontAttributes="Bold"/>
<Label
Text="{Binding LoginRequest.DeviceType}"
FontSize="Small"
Margin="0,0,0,21"/>
<Label
Text="{u:I18n IpAddress}"
IsVisible="{Binding ShowIpAddress}"
FontSize="Small"
FontAttributes="Bold"/>
<Label
Text="{Binding LoginRequest.IpAddress}"
IsVisible="{Binding ShowIpAddress}"
FontSize="Small"
Margin="0,0,0,21"/>
<Label
Text="{u:I18n Time}"
FontSize="Small"
FontAttributes="Bold"/>
<Label
Text="{Binding TimeOfRequestText}"
FontSize="Small"
Margin="0,0,0,57"/>
</StackLayout>
</ScrollView>
<Button
Text="{u:I18n ConfirmLogIn}"
Command="{Binding AcceptRequestCommand}"
Margin="0,0,0,17"
StyleClass="btn-primary"/>
<Button
Text="{u:I18n DenyLogIn}"
Command="{Binding RejectRequestCommand}"
StyleClass="btn-secundary"/>
</StackLayout>
</pages:BaseContentPage>

View File

@ -0,0 +1,31 @@
using Xamarin.Forms;
namespace Bit.App.Pages
{
public partial class LoginPasswordlessPage : BaseContentPage
{
private LoginPasswordlessViewModel _vm;
public LoginPasswordlessPage(LoginPasswordlessDetails loginPasswordlessDetails)
{
InitializeComponent();
_vm = BindingContext as LoginPasswordlessViewModel;
_vm.Page = this;
_vm.LoginRequest = loginPasswordlessDetails;
if (Device.RuntimePlatform == Device.iOS)
{
ToolbarItems.Add(_closeItem);
}
}
private async void Close_Clicked(object sender, System.EventArgs e)
{
if (DoOnce())
{
await Navigation.PopModalAsync();
}
}
}
}

View File

@ -0,0 +1,126 @@
using System;
using System.Linq;
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.Utilities;
using Xamarin.CommunityToolkit.ObjectModel;
using Xamarin.Forms;
namespace Bit.App.Pages
{
public class LoginPasswordlessViewModel : BaseViewModel
{
private IDeviceActionService _deviceActionService;
private IAuthService _authService;
private IPlatformUtilsService _platformUtilsService;
private ILogger _logger;
private LoginPasswordlessDetails _resquest;
public LoginPasswordlessViewModel()
{
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
_authService = ServiceContainer.Resolve<IAuthService>("authService");
_logger = ServiceContainer.Resolve<ILogger>("logger");
PageTitle = AppResources.LogInRequested;
AcceptRequestCommand = new AsyncCommand(() => PasswordlessLoginAsync(true),
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
RejectRequestCommand = new AsyncCommand(() => PasswordlessLoginAsync(false),
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
}
public ICommand AcceptRequestCommand { get; }
public ICommand RejectRequestCommand { get; }
public string LogInAttemptByLabel => LoginRequest != null ? string.Format(AppResources.LogInAttemptByXOnY, LoginRequest.Email, LoginRequest.Origin) : string.Empty;
public string TimeOfRequestText => CreateRequestDate(LoginRequest?.RequestDate);
public bool ShowIpAddress => !string.IsNullOrEmpty(LoginRequest?.IpAddress);
public LoginPasswordlessDetails LoginRequest
{
get => _resquest;
set
{
SetProperty(ref _resquest, value, additionalPropertyNames: new string[]
{
nameof(LogInAttemptByLabel),
nameof(TimeOfRequestText),
nameof(ShowIpAddress),
});
}
}
private async Task PasswordlessLoginAsync(bool approveRequest)
{
await _deviceActionService.ShowLoadingAsync(AppResources.Loading);
await _authService.PasswordlessLoginAsync(LoginRequest.Id, LoginRequest.PubKey, approveRequest);
await _deviceActionService.HideLoadingAsync();
await Page.Navigation.PopModalAsync();
_platformUtilsService.ShowToast("info", null, approveRequest ? AppResources.LogInAccepted : AppResources.LogInDenied);
}
private string CreateRequestDate(DateTime? requestDate)
{
if (!requestDate.HasValue)
{
return string.Empty;
}
var minutesSinceRequest = requestDate.Value.ToUniversalTime().Minute - DateTime.UtcNow.Minute;
if (minutesSinceRequest < 5)
{
return AppResources.JustNow;
}
if (minutesSinceRequest < 59)
{
return string.Format(AppResources.XMinutesAgo, minutesSinceRequest);
}
return requestDate.Value.ToShortTimeString();
}
private void HandleException(Exception ex)
{
Xamarin.Essentials.MainThread.InvokeOnMainThreadAsync(async () =>
{
await _deviceActionService.HideLoadingAsync();
await _platformUtilsService.ShowDialogAsync(AppResources.GenericErrorMessage);
}).FireAndForget();
_logger.Exception(ex);
}
}
public class LoginPasswordlessDetails
{
public string Id { get; set; }
public string Key { get; set; }
public string PubKey { get; set; }
public string Origin { get; set; }
public string Email { get; set; }
public string FingerprintPhrase { get; set; }
public DateTime RequestDate { get; set; }
public string DeviceType { get; set; }
public string IpAddress { get; set; }
}
}

View File

@ -30,7 +30,7 @@ namespace Bit.App.Pages
private readonly IKeyConnectorService _keyConnectorService;
private readonly IClipboardService _clipboardService;
private readonly ILogger _loggerService;
private readonly IPushNotificationService _pushNotificationService;
private const int CustomVaultTimeoutValue = -100;
private bool _supportsBiometric;
@ -42,6 +42,7 @@ namespace Bit.App.Pages
private string _vaultTimeoutActionDisplayValue;
private bool _showChangeMasterPassword;
private bool _reportLoggingEnabled;
private bool _approvePasswordlessLoginRequests;
private List<KeyValuePair<string, int?>> _vaultTimeouts =
new List<KeyValuePair<string, int?>>
@ -83,6 +84,7 @@ namespace Bit.App.Pages
_keyConnectorService = ServiceContainer.Resolve<IKeyConnectorService>("keyConnectorService");
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
_loggerService = ServiceContainer.Resolve<ILogger>("logger");
_pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>();
GroupedItems = new ObservableRangeCollection<ISettingsPageListItem>();
PageTitle = AppResources.Settings;
@ -133,6 +135,7 @@ namespace Bit.App.Pages
_showChangeMasterPassword = IncludeLinksWithSubscriptionInfo() &&
!await _keyConnectorService.GetUsesKeyConnector();
_reportLoggingEnabled = await _loggerService.IsEnabled();
_approvePasswordlessLoginRequests = await _stateService.GetApprovePasswordlessLoginsAsync();
BuildList();
}
@ -326,6 +329,38 @@ namespace Bit.App.Pages
BuildList();
}
public async Task ApproveLoginRequestsAsync()
{
var options = new[]
{
CreateSelectableOption(AppResources.Yes, _approvePasswordlessLoginRequests),
CreateSelectableOption(AppResources.No, !_approvePasswordlessLoginRequests),
};
var selection = await Page.DisplayActionSheet(AppResources.UseThisDeviceToApproveLoginRequestsMadeFromOtherDevices, AppResources.Cancel, null, options);
if (selection == null || selection == AppResources.Cancel)
{
return;
}
_approvePasswordlessLoginRequests = CompareSelection(selection, AppResources.Yes);
await _stateService.SetApprovePasswordlessLoginsAsync(_approvePasswordlessLoginRequests);
BuildList();
if (!_approvePasswordlessLoginRequests || await _pushNotificationService.AreNotificationsSettingsEnabledAsync())
{
return;
}
var openAppSettingsResult = await _platformUtilsService.ShowDialogAsync(AppResources.ReceivePushNotificationsForNewLoginRequests, title: string.Empty, confirmText: AppResources.Settings, cancelText: AppResources.NoThanks);
if (openAppSettingsResult)
{
_deviceActionService.OpenAppSettings();
}
}
public async Task VaultTimeoutActionAsync()
{
var options = _vaultTimeoutActions.Select(o =>
@ -504,6 +539,12 @@ namespace Bit.App.Pages
ExecuteAsync = () => UpdatePinAsync()
},
new SettingsPageListItem
{
Name = AppResources.ApproveLoginRequests,
SubLabel = _approvePasswordlessLoginRequests ? AppResources.On : AppResources.Off,
ExecuteAsync = () => ApproveLoginRequestsAsync()
},
new SettingsPageListItem
{
Name = AppResources.LockNow,
ExecuteAsync = () => LockAsync()

View File

@ -4151,6 +4151,127 @@ namespace Bit.App.Resources {
}
}
public static string LogInRequested {
get {
return ResourceManager.GetString("LogInRequested", resourceCulture);
}
}
public static string AreYouTryingToLogIn {
get {
return ResourceManager.GetString("AreYouTryingToLogIn", resourceCulture);
}
}
public static string LogInAttemptByXOnY {
get {
return ResourceManager.GetString("LogInAttemptByXOnY", resourceCulture);
}
}
public static string DeviceType {
get {
return ResourceManager.GetString("DeviceType", resourceCulture);
}
}
public static string IpAddress {
get {
return ResourceManager.GetString("IpAddress", resourceCulture);
}
}
public static string Time {
get {
return ResourceManager.GetString("Time", resourceCulture);
}
}
public static string Near {
get {
return ResourceManager.GetString("Near", resourceCulture);
}
}
public static string ConfirmLogIn {
get {
return ResourceManager.GetString("ConfirmLogIn", resourceCulture);
}
}
public static string DenyLogIn {
get {
return ResourceManager.GetString("DenyLogIn", resourceCulture);
}
}
public static string JustNow {
get {
return ResourceManager.GetString("JustNow", resourceCulture);
}
}
public static string XMinutesAgo {
get {
return ResourceManager.GetString("XMinutesAgo", resourceCulture);
}
}
public static string LogInAccepted {
get {
return ResourceManager.GetString("LogInAccepted", resourceCulture);
}
}
public static string LogInDenied {
get {
return ResourceManager.GetString("LogInDenied", resourceCulture);
}
}
public static string ApproveLoginRequests {
get {
return ResourceManager.GetString("ApproveLoginRequests", resourceCulture);
}
}
public static string UseThisDeviceToApproveLoginRequestsMadeFromOtherDevices {
get {
return ResourceManager.GetString("UseThisDeviceToApproveLoginRequestsMadeFromOtherDevices", resourceCulture);
}
}
public static string AllowNotifications {
get {
return ResourceManager.GetString("AllowNotifications", resourceCulture);
}
}
public static string ReceivePushNotificationsForNewLoginRequests {
get {
return ResourceManager.GetString("ReceivePushNotificationsForNewLoginRequests", resourceCulture);
}
}
public static string NoThanks {
get {
return ResourceManager.GetString("NoThanks", resourceCulture);
}
}
public static string ConfimLogInAttempForX {
get {
return ResourceManager.GetString("ConfimLogInAttempForX", resourceCulture);
}
}
public static string AllNotifications
{
get
{
return ResourceManager.GetString("AllNotifications", resourceCulture);
}
}
public static string PasswordType {
get {
return ResourceManager.GetString("PasswordType", resourceCulture);

View File

@ -2314,6 +2314,66 @@ select Add TOTP to store the key safely</value>
<data name="AreYouSureYouWantToEnableScreenCapture" xml:space="preserve">
<value>Are you sure you want to enable Screen Capture?</value>
</data>
<data name="LogInRequested" xml:space="preserve">
<value>Login requested</value>
</data>
<data name="AreYouTryingToLogIn" xml:space="preserve">
<value>Are you trying to log in?</value>
</data>
<data name="LogInAttemptByXOnY" xml:space="preserve">
<value>Login attempt by {0} on {1}</value>
</data>
<data name="DeviceType" xml:space="preserve">
<value>Device type</value>
</data>
<data name="IpAddress" xml:space="preserve">
<value>IP address</value>
</data>
<data name="Time" xml:space="preserve">
<value>Time</value>
</data>
<data name="Near" xml:space="preserve">
<value>Near</value>
</data>
<data name="ConfirmLogIn" xml:space="preserve">
<value>Confirm login</value>
</data>
<data name="DenyLogIn" xml:space="preserve">
<value>Deny login</value>
</data>
<data name="JustNow" xml:space="preserve">
<value>Just now</value>
</data>
<data name="XMinutesAgo" xml:space="preserve">
<value>{0} minutes ago</value>
</data>
<data name="LogInAccepted" xml:space="preserve">
<value>Login confirmed</value>
</data>
<data name="LogInDenied" xml:space="preserve">
<value>Login denied</value>
</data>
<data name="ApproveLoginRequests" xml:space="preserve">
<value>Approve login requests</value>
</data>
<data name="UseThisDeviceToApproveLoginRequestsMadeFromOtherDevices" xml:space="preserve">
<value>Use this device to approve login requests made from other devices.</value>
</data>
<data name="AllowNotifications" xml:space="preserve">
<value>Allow notifications</value>
</data>
<data name="ReceivePushNotificationsForNewLoginRequests" xml:space="preserve">
<value>Receive push notifications for new login requests</value>
</data>
<data name="NoThanks" xml:space="preserve">
<value>No thanks</value>
</data>
<data name="ConfimLogInAttempForX" xml:space="preserve">
<value>Confirm login attempt for {0}</value>
</data>
<data name="AllNotifications" xml:space="preserve">
<value>All notifications</value>
</data>
<data name="PasswordType" xml:space="preserve">
<value>Password Type</value>
</data>

View File

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.App.Abstractions;
namespace Bit.App.Services
@ -7,6 +8,11 @@ namespace Bit.App.Services
{
public bool IsRegisteredForPush => false;
public Task<bool> AreNotificationsSettingsEnabledAsync()
{
return Task.FromResult(false);
}
public Task<string> GetTokenAsync()
{
return Task.FromResult(null as string);
@ -21,5 +27,9 @@ namespace Bit.App.Services
{
return Task.FromResult(0);
}
public void DismissLocalNotification(string notificationId) { }
public void SendLocalNotification(string title, string message, string notificationId) { }
}
}

View File

@ -1,8 +1,11 @@
#if !FDROID
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Bit.App.Abstractions;
using Bit.App.Pages;
using Bit.App.Resources;
using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
@ -26,6 +29,7 @@ namespace Bit.App.Services
private IAppIdService _appIdService;
private IApiService _apiService;
private IMessagingService _messagingService;
private IPushNotificationService _pushNotificationService;
public async Task OnMessageAsync(JObject value, string deviceType)
{
@ -125,6 +129,27 @@ namespace Bit.App.Services
_messagingService.Send("logout");
}
break;
case NotificationType.AuthRequest:
var passwordlessLoginMessage = JsonConvert.DeserializeObject<PasswordlessRequestNotification>(notification.Payload);
// if the user has not enabled passwordless logins ignore requests
if (!await _stateService.GetApprovePasswordlessLoginsAsync(passwordlessLoginMessage?.UserId))
{
return;
}
// if there is a request modal opened ignore all incoming requests
if (App.Current.MainPage.Navigation.ModalStack.Any(p => p is NavigationPage navPage && navPage.CurrentPage is LoginPasswordlessPage))
{
return;
}
await _stateService.SetPasswordlessLoginNotificationAsync(passwordlessLoginMessage, passwordlessLoginMessage?.UserId);
var userEmail = await _stateService.GetEmailAsync(passwordlessLoginMessage?.UserId);
_pushNotificationService.SendLocalNotification(AppResources.LogInRequested, String.Format(AppResources.ConfimLogInAttempForX, userEmail), Constants.PasswordlessNotificationId);
_messagingService.Send("passwordlessLoginRequest", passwordlessLoginMessage);
break;
default:
break;
}
@ -204,6 +229,7 @@ namespace Bit.App.Services
_appIdService = ServiceContainer.Resolve<IAppIdService>("appIdService");
_apiService = ServiceContainer.Resolve<IApiService>("apiService");
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
_pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>();
_resolved = true;
}
}

View File

@ -71,6 +71,6 @@
<Color x:Key="NavigationBarTextColor">#ffffff</Color>
<Color x:Key="HyperlinkColor">#52bdfb</Color>
<Color x:Key="FingerprintPhrase">#F08DC7</Color>
<Color x:Key="ScanningToggleModeTextColor">#80BDFF</Color>
</ResourceDictionary>

View File

@ -71,6 +71,6 @@
<Color x:Key="NavigationBarTextColor">#ffffff</Color>
<Color x:Key="HyperlinkColor">#52bdfb</Color>
<Color x:Key="FingerprintPhrase">#F08DC7</Color>
<Color x:Key="ScanningToggleModeTextColor">#80BDFF</Color>
</ResourceDictionary>

View File

@ -71,6 +71,6 @@
<Color x:Key="NavigationBarTextColor">#ffffff</Color>
<Color x:Key="HyperlinkColor">#175DDC</Color>
<Color x:Key="FingerprintPhrase">#C01176</Color>
<Color x:Key="ScanningToggleModeTextColor">#80BDFF</Color>
</ResourceDictionary>

View File

@ -71,6 +71,6 @@
<Color x:Key="NavigationBarTextColor">#e5e9f0</Color>
<Color x:Key="HyperlinkColor">#81a1c1</Color>
<Color x:Key="FingerprintPhrase">#F08DC7</Color>
<Color x:Key="ScanningToggleModeTextColor">#80BDFF</Color>
</ResourceDictionary>

View File

@ -83,6 +83,8 @@ namespace Bit.Core.Abstractions
Task<SendResponse> PutSendAsync(string id, SendRequest request);
Task<SendResponse> PutSendRemovePasswordAsync(string id);
Task DeleteSendAsync(string id);
Task<PasswordlessLoginResponse> GetAuthRequestAsync(string id);
Task<PasswordlessLoginResponse> PutAuthRequestAsync(string id, string key, string masterPasswordHash, string deviceIdentifier, bool requestApproved);
Task<string> GetUsernameFromAsync(ForwardedEmailServiceType service, UsernameGeneratorConfig config);
}
}

View File

@ -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.Response;
namespace Bit.Core.Abstractions
{
@ -25,6 +26,10 @@ namespace Bit.Core.Abstractions
Task<AuthResult> LogInSsoAsync(string code, string codeVerifier, string redirectUrl, string orgId);
Task<AuthResult> LogInCompleteAsync(string email, string masterPassword, TwoFactorProviderType twoFactorProvider, string twoFactorToken, bool? remember = null);
Task<AuthResult> LogInTwoFactorAsync(TwoFactorProviderType twoFactorProvider, string twoFactorToken, string captchaToken, bool? remember = null);
Task<PasswordlessLoginResponse> GetPasswordlessLoginRequestByIdAsync(string id);
Task<PasswordlessLoginResponse> PasswordlessLoginAsync(string id, string pubKey, bool requestApproved);
void LogOut(Action callback);
void Init();
}

View File

@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Domain;
using Bit.Core.Models.Response;
using Bit.Core.Models.View;
namespace Bit.Core.Abstractions
@ -151,6 +152,10 @@ namespace Bit.Core.Abstractions
Task<bool> GetScreenCaptureAllowedAsync(string userId = null);
Task SetScreenCaptureAllowedAsync(bool value, string userId = null);
Task SaveExtensionActiveUserIdToStorageAsync(string userId);
Task<bool> GetApprovePasswordlessLoginsAsync(string userId = null);
Task SetApprovePasswordlessLoginsAsync(bool? value, string userId = null);
Task<PasswordlessRequestNotification> GetPasswordlessLoginNotificationAsync(string userId = null);
Task SetPasswordlessLoginNotificationAsync(PasswordlessRequestNotification value, string userId = null);
Task<UsernameGenerationOptions> GetUsernameGenerationOptionsAsync(string userId = null);
Task SetUsernameGenerationOptionsAsync(UsernameGenerationOptions value, string userId = null);
}

View File

@ -30,6 +30,8 @@
public static string EventCollectionKey = "eventCollection";
public static string RememberedEmailKey = "rememberedEmail";
public static string RememberedOrgIdentifierKey = "rememberedOrgIdentifier";
public const string PasswordlessNotificationId = "26072022";
public const string AndroidNotificationChannelId = "general_notification_channel";
public const int SelectFileRequestCode = 42;
public const int SelectFilePermissionRequestCode = 43;
public const int SaveFileRequestCode = 44;
@ -85,6 +87,8 @@
public static string ProtectedPinKey(string userId) => $"protectedPin_{userId}";
public static string LastSyncKey(string userId) => $"lastSync_{userId}";
public static string BiometricUnlockKey(string userId) => $"biometricUnlock_{userId}";
public static string ApprovePasswordlessLoginsKey(string userId) => $"approvePasswordlessLogins_{userId}";
public static string PasswordlessLoginNofiticationKey(string userId) => $"passwordlessLoginNofitication_{userId}";
public static string UsernameGenOptionsKey(string userId) => $"usernameGenerationOptions_{userId}";
}
}

View File

@ -16,5 +16,12 @@
SyncSettings = 10,
LogOut = 11,
SyncSendCreate = 12,
SyncSendUpdate = 13,
SyncSendDelete = 14,
AuthRequest = 15,
AuthRequestResponse = 16,
}
}

View File

@ -0,0 +1,21 @@
using System;
namespace Bit.Core.Models.Request
{
public class PasswordlessLoginRequest
{
public PasswordlessLoginRequest(string key, string masterPasswordHash, string deviceIdentifier,
bool requestApproved)
{
Key = key ?? throw new ArgumentNullException(nameof(key));
MasterPasswordHash = masterPasswordHash ?? throw new ArgumentNullException(nameof(masterPasswordHash));
DeviceIdentifier = deviceIdentifier ?? throw new ArgumentNullException(nameof(deviceIdentifier));
RequestApproved = requestApproved;
}
public string Key { get; set; }
public string MasterPasswordHash { get; set; }
public string DeviceIdentifier { get; set; }
public bool RequestApproved { get; set; }
}
}

View File

@ -33,4 +33,10 @@ namespace Bit.Core.Models.Response
public string UserId { get; set; }
public DateTime Date { get; set; }
}
public class PasswordlessRequestNotification
{
public string UserId { get; set; }
public string Id { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using System;
using Bit.Core.Enums;
namespace Bit.Core.Models.Response
{
public class PasswordlessLoginResponse
{
public string Id { get; set; }
public string PublicKey { get; set; }
public string RequestDeviceType { get; set; }
public string RequestIpAddress { get; set; }
public string RequestFingerprint { get; set; }
public string Key { get; set; }
public string MasterPasswordHash { get; set; }
public DateTime CreationDate { get; set; }
public bool RequestApproved { get; set; }
public string Origin { get; set; }
}
}

View File

@ -534,6 +534,21 @@ namespace Bit.Core.Services
#endregion
#region PasswordlessLogin
public Task<PasswordlessLoginResponse> GetAuthRequestAsync(string id)
{
return SendAsync<object, PasswordlessLoginResponse>(HttpMethod.Get, $"/auth-requests/{id}", null, true, true);
}
public Task<PasswordlessLoginResponse> PutAuthRequestAsync(string id, string encKey, string encMasterPasswordHash, string deviceIdentifier, bool requestApproved)
{
var request = new PasswordlessLoginRequest(encKey, encMasterPasswordHash, deviceIdentifier, requestApproved);
return SendAsync<object, PasswordlessLoginResponse>(HttpMethod.Put, $"/auth-requests/{id}", request, true, true);
}
#endregion
#region Helpers
public async Task<string> GetActiveBearerTokenAsync()

View File

@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Domain;
using Bit.Core.Models.Request;
using Bit.Core.Models.Response;
using Bit.Core.Utilities;
namespace Bit.Core.Services
@ -468,5 +470,20 @@ namespace Bit.Core.Services
TwoFactorProvidersData = null;
SelectedTwoFactorProviderType = null;
}
public async Task<PasswordlessLoginResponse> GetPasswordlessLoginRequestByIdAsync(string id)
{
return await _apiService.GetAuthRequestAsync(id);
}
public async Task<PasswordlessLoginResponse> PasswordlessLoginAsync(string id, string pubKey, bool requestApproved)
{
var publicKey = CoreHelpers.Base64UrlDecode(pubKey);
var masterKey = await _cryptoService.GetKeyAsync();
var encryptedKey = await _cryptoService.RsaEncryptAsync(masterKey.EncKey, publicKey);
var encryptedMasterPassword = await _cryptoService.RsaEncryptAsync(Encoding.UTF8.GetBytes(await _stateService.GetKeyHashAsync()), publicKey);
var deviceId = await _appIdService.GetAppIdAsync();
return await _apiService.PutAuthRequestAsync(id, encryptedKey.EncryptedString, encryptedMasterPassword.EncryptedString, deviceId, requestApproved);
}
}
}

View File

@ -7,6 +7,7 @@ using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Domain;
using Bit.Core.Models.Response;
using Bit.Core.Models.View;
using Bit.Core.Utilities;
@ -1260,6 +1261,37 @@ namespace Bit.Core.Services
await SetValueAsync(key, value, reconciledOptions);
}
public async Task<bool> GetApprovePasswordlessLoginsAsync(string userId = null)
{
var reconciledOptions = ReconcileOptions(new StorageOptions { UserId = userId },
await GetDefaultStorageOptionsAsync());
var key = Constants.ApprovePasswordlessLoginsKey(reconciledOptions.UserId);
return await GetValueAsync<bool?>(key, reconciledOptions) ?? false;
}
public async Task SetApprovePasswordlessLoginsAsync(bool? value, string userId = null)
{
var reconciledOptions = ReconcileOptions(new StorageOptions { UserId = userId },
await GetDefaultStorageOptionsAsync());
var key = Constants.ApprovePasswordlessLoginsKey(reconciledOptions.UserId);
await SetValueAsync(key, value, reconciledOptions);
}
public async Task<PasswordlessRequestNotification> GetPasswordlessLoginNotificationAsync(string userId = null)
{
var reconciledOptions = ReconcileOptions(new StorageOptions { UserId = userId },
await GetDefaultStorageOptionsAsync());
var key = Constants.PasswordlessLoginNofiticationKey(reconciledOptions.UserId);
return await GetValueAsync<PasswordlessRequestNotification>(key, reconciledOptions);
}
public async Task SetPasswordlessLoginNotificationAsync(PasswordlessRequestNotification value, string userId = null)
{
var reconciledOptions = ReconcileOptions(new StorageOptions { UserId = userId },
await GetDefaultStorageOptionsAsync());
var key = Constants.PasswordlessLoginNofiticationKey(reconciledOptions.UserId);
await SetValueAsync(key, value, reconciledOptions);
}
// Helpers
private async Task<T> GetValueAsync<T>(string key, StorageOptions options)
@ -1455,6 +1487,7 @@ namespace Bit.Core.Services
await SetEncryptedPasswordGenerationHistoryAsync(null, userId);
await SetEncryptedSendsAsync(null, userId);
await SetSettingsAsync(null, userId);
await SetApprovePasswordlessLoginsAsync(null, userId);
if (userInitiated)
{
@ -1474,6 +1507,7 @@ namespace Bit.Core.Services
await SetAutoDarkThemeAsync(null, userId);
await SetAddSitePromptShownAsync(null, userId);
await SetPasswordGenerationOptionsAsync(null, userId);
await SetApprovePasswordlessLoginsAsync(null, userId);
await SetUsernameGenerationOptionsAsync(null, userId);
}
}

View File

@ -643,5 +643,11 @@ namespace Bit.iOS.Core.Services
_deviceActionService.PickedDocument(url);
}
}
public void OpenAppSettings()
{
var url = new NSUrl(UIApplication.OpenSettingsUrlString);
UIApplication.SharedApplication.OpenUrl(url);
}
}
}

View File

@ -16,6 +16,7 @@ using Bit.iOS.Services;
using CoreNFC;
using Foundation;
using UIKit;
using UserNotifications;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
@ -56,7 +57,6 @@ namespace Bit.iOS
LoadApplication(new App.App(null));
iOSCoreHelpers.AppearanceAdjustments();
ZXing.Net.Mobile.Forms.iOS.Platform.Init();
_broadcasterService.Subscribe(nameof(AppDelegate), async (message) =>
{
try

View File

@ -83,7 +83,6 @@ namespace Bit.iOS.Services
public void WillPresentNotification(UNUserNotificationCenter center, UNNotification notification, Action<UNNotificationPresentationOptions> completionHandler)
{
Debug.WriteLine($"{TAG} WillPresentNotification {notification?.Request?.Content?.UserInfo}");
OnMessageReceived(notification?.Request?.Content?.UserInfo);
completionHandler(UNNotificationPresentationOptions.Alert);
}

View File

@ -1,6 +1,10 @@
using System.Diagnostics;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Bit.App.Abstractions;
using Bit.Core.Services;
using Foundation;
using UIKit;
using UserNotifications;
@ -19,6 +23,12 @@ namespace Bit.iOS.Services
public bool IsRegisteredForPush => UIApplication.SharedApplication.IsRegisteredForRemoteNotifications;
public async Task<bool> AreNotificationsSettingsEnabledAsync()
{
var settings = await UNUserNotificationCenter.Current.GetNotificationSettingsAsync();
return settings.AlertSetting == UNNotificationSetting.Enabled;
}
public async Task RegisterAsync()
{
Debug.WriteLine($"{TAG} RegisterAsync");
@ -58,5 +68,39 @@ namespace Bit.iOS.Services
NSUserDefaults.StandardUserDefaults.Synchronize();
return Task.FromResult(0);
}
public void SendLocalNotification(string title, string message, string notificationId)
{
if (string.IsNullOrEmpty(notificationId))
{
throw new ArgumentNullException("notificationId cannot be null or empty.");
}
var content = new UNMutableNotificationContent()
{
Title = title,
Body = message
};
var request = UNNotificationRequest.FromIdentifier(notificationId, content, null);
UNUserNotificationCenter.Current.AddNotificationRequest(request, (err) =>
{
if (err != null)
{
Logger.Instance.Exception(new Exception($"Failed to schedule notification: {err}"));
}
});
}
public void DismissLocalNotification(string notificationId)
{
if (string.IsNullOrEmpty(notificationId))
{
return;
}
UNUserNotificationCenter.Current.RemovePendingNotificationRequests(new string[] { notificationId });
UNUserNotificationCenter.Current.RemoveDeliveredNotifications(new string[] { notificationId });
}
}
}