1
0
mirror of https://github.com/bitwarden/mobile synced 2026-01-10 12:33:28 +00:00

[SG-174] Login with Device Request - Mobile (#2167)

* [SG-174] Add new login request services to Api

* [SG-174] Fix typo

* [SG-174] Enable login with device button.

* [SG-174] Add new login request page and viewmodel

* [SG-174] Add new text resources

* [SG-174] Add new RSA Decrypt method with string param

* [SG-174] Change create login request method

* [SG-174] Add new method to auth service to login passwordless

* [SG-174] Refactor login helper method to work with passwordless

* [SG-174] Fix service registration

* [SG-174] Update token request to support passwordless

* [SG-174] Update Api service with passwordless methods

* [SG-174] Fix App csproj references

* [SG-174] Remove unnecessary argument

* [SG-174] dotnet format

* [SG-174] Fixed iOS Extensions

* [SG-174] Change Command to ICommand

* [SG-174] Change Gesture Recognizer to Command

* [SG-174] Fix close action

* [SG-174] Code format

* [SG-174] Fix android frame shadow bug

* [SG-174] PR fixes
This commit is contained in:
André Bispo
2022-11-09 16:25:48 +00:00
committed by GitHub
parent 04ed47d545
commit 9ae269dd57
25 changed files with 618 additions and 15 deletions

View File

@@ -125,6 +125,9 @@
<Compile Update="Pages\Accounts\LoginPasswordlessPage.xaml.cs">
<DependentUpon>LoginPasswordlessPage.xaml</DependentUpon>
</Compile>
<Compile Update="Pages\Accounts\LoginPasswordlessRequestPage.xaml.cs">
<DependentUpon>LoginPasswordlessRequestPage.xaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>

View File

@@ -8,6 +8,7 @@
Padding="1"
StyleClass="btn-icon-secondary"
BackgroundColor="{Binding IconLabelBorderColor, Source={x:Reference _iconLabelButton}}"
BorderColor="Transparent"
HasShadow="False">
<Frame.GestureRecognizers>
<TapGestureRecognizer Command="{Binding ButtonCommand, Source={x:Reference _iconLabelButton}}" />
@@ -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">
<StackLayout

View File

@@ -19,7 +19,7 @@ namespace Bit.App.Controls
nameof(Label), typeof(string), typeof(IconLabelButton));
public static readonly BindableProperty ButtonCommandProperty = BindableProperty.Create(
nameof(ButtonCommand), typeof(Command), typeof(IconLabelButton));
nameof(ButtonCommand), typeof(ICommand), typeof(IconLabelButton));
public static readonly BindableProperty IconLabelColorProperty = BindableProperty.Create(
nameof(IconLabelColor), typeof(Color), typeof(IconLabelButton), Color.White);

View File

@@ -110,8 +110,8 @@
VerticalOptions="CenterAndExpand"
Icon="{Binding Source={x:Static core:BitwardenIcons.Device}}"
Label="{u:I18n LogInWithAnotherDevice}"
ButtonCommand="{Binding LogInCommand}"
IsVisible="False"/>
ButtonCommand="{Binding LogInWithDeviceCommand}"
IsVisible="{Binding IsKnownDevice}"/>
<controls:IconLabelButton
HorizontalOptions="Fill"
VerticalOptions="CenterAndExpand"

View File

@@ -26,9 +26,9 @@ namespace Bit.App.Pages
_vm.Page = this;
_vm.StartTwoFactorAction = () => 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);

View File

@@ -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; }

View File

@@ -0,0 +1,76 @@
<?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.LoginPasswordlessRequestPage"
xmlns:pages="clr-namespace:Bit.App.Pages"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:u="clr-namespace:Bit.App.Utilities"
x:DataType="pages:LoginPasswordlessRequestViewModel"
Title="{Binding PageTitle}">
<ContentPage.BindingContext>
<pages:LoginPasswordlessRequestViewModel />
</ContentPage.BindingContext>
<ContentPage.ToolbarItems>
<ToolbarItem Text="{u:I18n Close}" Command="{Binding CloseCommand}" Order="Primary" Priority="-1" x:Name="_closeItem"/>
</ContentPage.ToolbarItems>
<ScrollView>
<StackLayout
Padding="7, 0, 7, 20">
<Label
Text="{u:I18n LogInInitiated}"
FontSize="Title"
FontAttributes="Bold"
Margin="0,14,0,21"/>
<Label
Text="{u:I18n ANotificationHasBeenSentToYourDevice}"
FontSize="Small"
Margin="0,0,0,10"/>
<Label
Text="{u:I18n PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice}"
FontSize="Small"
Margin="0,0,0,24"/>
<Label
Text="{u:I18n FingerprintPhrase}"
FontSize="Small"
FontAttributes="Bold"/>
<controls:MonoLabel
FormattedText="{Binding FingerprintPhrase}"
FontSize="Medium"
TextColor="{DynamicResource FingerprintPhrase}"/>
<Label
Text="{u:I18n ResendNotification}"
StyleClass="text-md"
HorizontalOptions="Start"
Margin="0,40,0,0"
TextColor="{DynamicResource HyperlinkColor}">
<Label.GestureRecognizers>
<TapGestureRecognizer Command="{Binding CreatePasswordlessLoginCommand}" />
</Label.GestureRecognizers>
</Label>
<StackLayout
Orientation="Horizontal"
Margin="0,30,0,0">
<Label
Text="{u:I18n NeedAnotherOption}"
FontSize="Small"
VerticalTextAlignment="End"/>
<Label
Text="{u:I18n ViewAllLoginOptions}"
StyleClass="text-md"
VerticalTextAlignment="End"
VerticalOptions="CenterAndExpand"
Margin="5, 0"
TextColor="{DynamicResource HyperlinkColor}">
<Label.GestureRecognizers>
<TapGestureRecognizer Command="{Binding CloseCommand}" />
</Label.GestureRecognizers>
</Label>
</StackLayout>
</StackLayout>
</ScrollView>
</pages:BaseContentPage>

View File

@@ -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));
}
}
}

View File

@@ -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<byte[], byte[]> _requestKeyPair;
public LoginPasswordlessRequestViewModel()
{
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>();
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>();
_environmentService = ServiceContainer.Resolve<IEnvironmentService>();
_authService = ServiceContainer.Resolve<IAuthService>();
_syncService = ServiceContainer.Resolve<ISyncService>();
_i18nService = ServiceContainer.Resolve<II18nService>();
_stateService = ServiceContainer.Resolve<IStateService>();
_logger = ServiceContainer.Resolve<ILogger>();
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);
}
}
}

View File

@@ -16,6 +16,22 @@ namespace Bit.App.Pages
protected abstract IPlatformUtilsService platformUtilsService { get; }
protected string _captchaToken = null;
protected async Task<bool> HandleCaptchaAsync(string captchaSiteKey, bool needsCaptcha, Func<Task> onSuccess)
{
if (!needsCaptcha)
{
_captchaToken = null;
return false;
}
if (await HandleCaptchaAsync(captchaSiteKey))
{
await onSuccess();
_captchaToken = null;
}
return true;
}
protected async Task<bool> HandleCaptchaAsync(string CaptchaSiteKey)
{
var callbackUri = "bitwarden://captcha-callback";

View File

@@ -481,6 +481,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to A notification has been sent to your device..
/// </summary>
public static string ANotificationHasBeenSentToYourDevice {
get {
return ResourceManager.GetString("ANotificationHasBeenSentToYourDevice", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to API access token.
/// </summary>
@@ -3480,6 +3489,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Log in initiated.
/// </summary>
public static string LogInInitiated {
get {
return ResourceManager.GetString("LogInInitiated", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Login.
/// </summary>
@@ -3966,6 +3984,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Need another option?.
/// </summary>
public static string NeedAnotherOption {
get {
return ResourceManager.GetString("NeedAnotherOption", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Never.
/// </summary>
@@ -4705,6 +4732,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device..
/// </summary>
public static string PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice {
get {
return ResourceManager.GetString("PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Plus addressed email.
/// </summary>
@@ -5003,6 +5039,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Resend notification.
/// </summary>
public static string ResendNotification {
get {
return ResourceManager.GetString("ResendNotification", resourceCulture);
}
}
/// <summary>
/// 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..
/// </summary>
@@ -6542,6 +6587,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to View all log in options.
/// </summary>
public static string ViewAllLoginOptions {
get {
return ResourceManager.GetString("ViewAllLoginOptions", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to View item.
/// </summary>

View File

@@ -2491,4 +2491,22 @@ Do you want to switch to this account?</value>
<data name="LogInWithAnotherDevice" xml:space="preserve">
<value>Log In with another device</value>
</data>
<data name="LogInInitiated" xml:space="preserve">
<value>Log in initiated</value>
</data>
<data name="ANotificationHasBeenSentToYourDevice" xml:space="preserve">
<value>A notification has been sent to your device.</value>
</data>
<data name="PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice" xml:space="preserve">
<value>Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device.</value>
</data>
<data name="ResendNotification" xml:space="preserve">
<value>Resend notification</value>
</data>
<data name="NeedAnotherOption" xml:space="preserve">
<value>Need another option?</value>
</data>
<data name="ViewAllLoginOptions" xml:space="preserve">
<value>View all log in options</value>
</data>
</root>