mirror of
https://github.com/bitwarden/mobile
synced 2025-12-18 17:23:18 +00:00
[PM-3394] Fix login with device for passwordless approvals (#2686)
* set activeUserId to null when logging in a new account - Also stop the user key from being set in inactive accounts * get token for login with device if approving device doesn't have master key * add comment * simplify logic * check for route instead of using isAuthenticated - we don't clear the user id when logging in new account - this means we can't trust the state service, so we have to base our logic off the route in login with device * use authenticated auth request for tde login with device * [PM-3394] Add authingWithSso parameter to LoginPasswordlessRequestPage. * pr feedback * [PM-3394] Refactor condition Co-authored-by: Federico Maccaroni <fedemkr@gmail.com> --------- Co-authored-by: André Bispo <abispo@bitwarden.com> Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>
This commit is contained in:
@@ -50,13 +50,13 @@ namespace Bit.App.Pages
|
|||||||
|
|
||||||
private async Task StartLoginWithDeviceAsync()
|
private async Task StartLoginWithDeviceAsync()
|
||||||
{
|
{
|
||||||
var page = new LoginPasswordlessRequestPage(_vm.Email, AuthRequestType.AuthenticateAndUnlock, _appOptions);
|
var page = new LoginPasswordlessRequestPage(_vm.Email, AuthRequestType.AuthenticateAndUnlock, _appOptions, true);
|
||||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RequestAdminApprovalAsync()
|
private async Task RequestAdminApprovalAsync()
|
||||||
{
|
{
|
||||||
var page = new LoginPasswordlessRequestPage(_vm.Email, AuthRequestType.AdminApproval, _appOptions);
|
var page = new LoginPasswordlessRequestPage(_vm.Email, AuthRequestType.AdminApproval, _appOptions, true);
|
||||||
await Navigation.PushModalAsync(new NavigationPage(page));
|
await Navigation.PushModalAsync(new NavigationPage(page));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Bit.App.Pages
|
|||||||
private LoginPasswordlessRequestViewModel _vm;
|
private LoginPasswordlessRequestViewModel _vm;
|
||||||
private readonly AppOptions _appOptions;
|
private readonly AppOptions _appOptions;
|
||||||
|
|
||||||
public LoginPasswordlessRequestPage(string email, AuthRequestType authRequestType, AppOptions appOptions = null)
|
public LoginPasswordlessRequestPage(string email, AuthRequestType authRequestType, AppOptions appOptions = null, bool authingWithSso = false)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_appOptions = appOptions;
|
_appOptions = appOptions;
|
||||||
@@ -21,6 +21,7 @@ namespace Bit.App.Pages
|
|||||||
_vm.Page = this;
|
_vm.Page = this;
|
||||||
_vm.Email = email;
|
_vm.Email = email;
|
||||||
_vm.AuthRequestType = authRequestType;
|
_vm.AuthRequestType = authRequestType;
|
||||||
|
_vm.AuthingWithSso = authingWithSso;
|
||||||
_vm.StartTwoFactorAction = () => Device.BeginInvokeOnMainThread(async () => await StartTwoFactorAsync());
|
_vm.StartTwoFactorAction = () => Device.BeginInvokeOnMainThread(async () => await StartTwoFactorAsync());
|
||||||
_vm.LogInSuccessAction = () => Device.BeginInvokeOnMainThread(async () => await LogInSuccessAsync());
|
_vm.LogInSuccessAction = () => Device.BeginInvokeOnMainThread(async () => await LogInSuccessAsync());
|
||||||
_vm.UpdateTempPasswordAction = () => Device.BeginInvokeOnMainThread(async () => await UpdateTempPasswordAsync());
|
_vm.UpdateTempPasswordAction = () => Device.BeginInvokeOnMainThread(async () => await UpdateTempPasswordAsync());
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ namespace Bit.App.Pages
|
|||||||
public Action LogInSuccessAction { get; set; }
|
public Action LogInSuccessAction { get; set; }
|
||||||
public Action UpdateTempPasswordAction { get; set; }
|
public Action UpdateTempPasswordAction { get; set; }
|
||||||
public Action CloseAction { get; set; }
|
public Action CloseAction { get; set; }
|
||||||
|
public bool AuthingWithSso { get; set; }
|
||||||
|
|
||||||
public ICommand CreatePasswordlessLoginCommand { get; }
|
public ICommand CreatePasswordlessLoginCommand { get; }
|
||||||
public ICommand CloseCommand { get; }
|
public ICommand CloseCommand { get; }
|
||||||
@@ -233,7 +234,7 @@ namespace Bit.App.Pages
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
PasswordlessLoginResponse response = null;
|
PasswordlessLoginResponse response = null;
|
||||||
if (await _stateService.IsAuthenticatedAsync())
|
if (AuthingWithSso)
|
||||||
{
|
{
|
||||||
response = await _authService.GetPasswordlessLoginRequestByIdAsync(_requestId);
|
response = await _authService.GetPasswordlessLoginRequestByIdAsync(_requestId);
|
||||||
}
|
}
|
||||||
@@ -242,14 +243,14 @@ namespace Bit.App.Pages
|
|||||||
response = await _authService.GetPasswordlessLoginResquestAsync(_requestId, _requestAccessCode);
|
response = await _authService.GetPasswordlessLoginResquestAsync(_requestId, _requestAccessCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.RequestApproved == null || !response.RequestApproved.Value)
|
if (response?.RequestApproved != true)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
StopCheckLoginRequestStatus();
|
StopCheckLoginRequestStatus();
|
||||||
|
|
||||||
var authResult = await _authService.LogInPasswordlessAsync(Email, _requestAccessCode, _requestId, _requestKeyPair.Item2, response.Key, response.MasterPasswordHash);
|
var authResult = await _authService.LogInPasswordlessAsync(AuthingWithSso, Email, _requestAccessCode, _requestId, _requestKeyPair.Item2, response.Key, response.MasterPasswordHash);
|
||||||
await AppHelpers.ResetInvalidUnlockAttemptsAsync();
|
await AppHelpers.ResetInvalidUnlockAttemptsAsync();
|
||||||
|
|
||||||
if (authResult == null && await _stateService.IsAuthenticatedAsync())
|
if (authResult == null && await _stateService.IsAuthenticatedAsync())
|
||||||
|
|||||||
@@ -240,9 +240,9 @@ namespace Bit.App.Pages
|
|||||||
else if (pendingRequest != null)
|
else if (pendingRequest != null)
|
||||||
{
|
{
|
||||||
var authRequest = await _authService.GetPasswordlessLoginRequestByIdAsync(pendingRequest.Id);
|
var authRequest = await _authService.GetPasswordlessLoginRequestByIdAsync(pendingRequest.Id);
|
||||||
if (authRequest != null && authRequest.RequestApproved != null && authRequest.RequestApproved.Value)
|
if (authRequest?.RequestApproved == true)
|
||||||
{
|
{
|
||||||
var authResult = await _authService.LogInPasswordlessAsync(await _stateService.GetActiveUserEmailAsync(), authRequest.RequestAccessCode, pendingRequest.Id, pendingRequest.PrivateKey, authRequest.Key, authRequest.MasterPasswordHash);
|
var authResult = await _authService.LogInPasswordlessAsync(true, await _stateService.GetActiveUserEmailAsync(), authRequest.RequestAccessCode, pendingRequest.Id, pendingRequest.PrivateKey, authRequest.Key, authRequest.MasterPasswordHash);
|
||||||
if (authResult == null && await _stateService.IsAuthenticatedAsync())
|
if (authResult == null && await _stateService.IsAuthenticatedAsync())
|
||||||
{
|
{
|
||||||
await Xamarin.Essentials.MainThread.InvokeOnMainThreadAsync(
|
await Xamarin.Essentials.MainThread.InvokeOnMainThreadAsync(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ namespace Bit.Core.Abstractions
|
|||||||
Task<AuthResult> LogInSsoAsync(string code, string codeVerifier, string redirectUrl, string orgId);
|
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> 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<AuthResult> LogInTwoFactorAsync(TwoFactorProviderType twoFactorProvider, string twoFactorToken, string captchaToken, bool? remember = null);
|
||||||
Task<AuthResult> LogInPasswordlessAsync(string email, string accessCode, string authRequestId, byte[] decryptionKey, string userKeyCiphered, string localHashedPasswordCiphered);
|
Task<AuthResult> LogInPasswordlessAsync(bool authingWithSso, string email, string accessCode, string authRequestId, byte[] decryptionKey, string userKeyCiphered, string localHashedPasswordCiphered);
|
||||||
|
|
||||||
Task<List<PasswordlessLoginResponse>> GetPasswordlessLoginRequestsAsync();
|
Task<List<PasswordlessLoginResponse>> GetPasswordlessLoginRequestsAsync();
|
||||||
Task<List<PasswordlessLoginResponse>> GetActivePasswordlessLoginRequestsAsync();
|
Task<List<PasswordlessLoginResponse>> GetActivePasswordlessLoginRequestsAsync();
|
||||||
|
|||||||
@@ -200,12 +200,13 @@ namespace Bit.Core.Services
|
|||||||
return !await _policyService.EvaluateMasterPassword(strength.Value, masterPassword, _masterPasswordPolicy);
|
return !await _policyService.EvaluateMasterPassword(strength.Value, masterPassword, _masterPasswordPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResult> LogInPasswordlessAsync(string email, string accessCode, string authRequestId, byte[] decryptionKey, string encryptedAuthRequestKey, string masterKeyHash)
|
public async Task<AuthResult> LogInPasswordlessAsync(bool authingWithSso, string email, string accessCode, string authRequestId, byte[] decryptionKey, string encryptedAuthRequestKey, string masterKeyHash)
|
||||||
{
|
{
|
||||||
var decryptedKey = await _cryptoService.RsaDecryptAsync(encryptedAuthRequestKey, decryptionKey);
|
var decryptedKey = await _cryptoService.RsaDecryptAsync(encryptedAuthRequestKey, decryptionKey);
|
||||||
|
|
||||||
// On SSO flow user is already AuthN
|
// If the user is already authenticated, we can just set the key
|
||||||
if (await _stateService.IsAuthenticatedAsync())
|
// Note: We can't check for the existance of an access token here because the active user id may not be null
|
||||||
|
if (authingWithSso)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(masterKeyHash))
|
if (string.IsNullOrEmpty(masterKeyHash))
|
||||||
{
|
{
|
||||||
@@ -222,10 +223,13 @@ namespace Bit.Core.Services
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The approval device may not have a master key hash if it authenticated with a passwordless method
|
||||||
if (string.IsNullOrEmpty(masterKeyHash) && decryptionKey != null)
|
if (string.IsNullOrEmpty(masterKeyHash) && decryptionKey != null)
|
||||||
{
|
{
|
||||||
|
var authResult = await LogInHelperAsync(email, accessCode, null, null, null, null, null, null, null, null, null, authRequestId: authRequestId);
|
||||||
|
// Only set the user key after the login helper so we have a user id
|
||||||
await _cryptoService.SetUserKeyAsync(new UserKey(decryptedKey));
|
await _cryptoService.SetUserKeyAsync(new UserKey(decryptedKey));
|
||||||
return null;
|
return authResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
var decKeyHash = await _cryptoService.RsaDecryptAsync(masterKeyHash, decryptionKey);
|
var decKeyHash = await _cryptoService.RsaDecryptAsync(masterKeyHash, decryptionKey);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ namespace Bit.Core.Services
|
|||||||
|
|
||||||
public async Task<UserKey> GetUserKeyWithLegacySupportAsync(string userId = null)
|
public async Task<UserKey> GetUserKeyWithLegacySupportAsync(string userId = null)
|
||||||
{
|
{
|
||||||
var userKey = await GetUserKeyAsync();
|
var userKey = await GetUserKeyAsync(userId);
|
||||||
if (userKey != null)
|
if (userKey != null)
|
||||||
{
|
{
|
||||||
return userKey;
|
return userKey;
|
||||||
@@ -71,7 +71,7 @@ namespace Bit.Core.Services
|
|||||||
|
|
||||||
// Legacy support: encryption used to be done with the master key (derived from master password).
|
// Legacy support: encryption used to be done with the master key (derived from master password).
|
||||||
// Users who have not migrated will have a null user key and must use the master key instead.
|
// Users who have not migrated will have a null user key and must use the master key instead.
|
||||||
return new UserKey((await GetMasterKeyAsync()).Key);
|
return new UserKey((await GetMasterKeyAsync(userId)).Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HasUserKeyAsync(string userId = null)
|
public async Task<bool> HasUserKeyAsync(string userId = null)
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ namespace Bit.Core.Services
|
|||||||
|
|
||||||
public async Task<bool> IsLockedAsync(string userId = null)
|
public async Task<bool> IsLockedAsync(string userId = null)
|
||||||
{
|
{
|
||||||
|
// When checking if we need to lock inactive account, we don't want to
|
||||||
|
// set the key, so we only check if the account has an auto unlock key
|
||||||
|
if (userId != null && await _stateService.GetActiveUserIdAsync() != userId)
|
||||||
|
{
|
||||||
|
return await _cryptoService.HasAutoUnlockKeyAsync(userId);
|
||||||
|
}
|
||||||
|
|
||||||
var biometricSet = await IsBiometricLockSetAsync(userId);
|
var biometricSet = await IsBiometricLockSetAsync(userId);
|
||||||
if (biometricSet && await _stateService.GetBiometricLockedAsync(userId))
|
if (biometricSet && await _stateService.GetBiometricLockedAsync(userId))
|
||||||
{
|
{
|
||||||
@@ -67,14 +74,11 @@ namespace Bit.Core.Services
|
|||||||
|
|
||||||
if (!await _cryptoService.HasUserKeyAsync(userId))
|
if (!await _cryptoService.HasUserKeyAsync(userId))
|
||||||
{
|
{
|
||||||
if (await _cryptoService.HasAutoUnlockKeyAsync(userId))
|
if (!await _cryptoService.HasAutoUnlockKeyAsync(userId))
|
||||||
{
|
|
||||||
await _cryptoService.SetUserKeyAsync(await _cryptoService.GetAutoUnlockKeyAsync(userId));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
await _cryptoService.SetUserKeyAsync(await _cryptoService.GetAutoUnlockKeyAsync(userId), userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check again to verify auto key was set
|
// Check again to verify auto key was set
|
||||||
|
|||||||
@@ -511,11 +511,11 @@ namespace Bit.iOS.Autofill
|
|||||||
LogoutIfAuthed();
|
LogoutIfAuthed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LaunchLoginWithDevice(AuthRequestType authRequestType, string email = null)
|
private void LaunchLoginWithDevice(AuthRequestType authRequestType, string email = null, bool authingWithSso = false)
|
||||||
{
|
{
|
||||||
var appOptions = new AppOptions { IosExtension = true };
|
var appOptions = new AppOptions { IosExtension = true };
|
||||||
var app = new App.App(appOptions);
|
var app = new App.App(appOptions);
|
||||||
var loginWithDevicePage = new LoginPasswordlessRequestPage(email, authRequestType, appOptions);
|
var loginWithDevicePage = new LoginPasswordlessRequestPage(email, authRequestType, appOptions, authingWithSso);
|
||||||
ThemeManager.SetTheme(app.Resources);
|
ThemeManager.SetTheme(app.Resources);
|
||||||
ThemeManager.ApplyResourcesTo(loginWithDevicePage);
|
ThemeManager.ApplyResourcesTo(loginWithDevicePage);
|
||||||
if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm)
|
if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm)
|
||||||
@@ -632,8 +632,8 @@ namespace Bit.iOS.Autofill
|
|||||||
if (loginApproveDevicePage.BindingContext is LoginApproveDeviceViewModel vm)
|
if (loginApproveDevicePage.BindingContext is LoginApproveDeviceViewModel vm)
|
||||||
{
|
{
|
||||||
vm.LogInWithMasterPasswordAction = () => DismissViewController(false, () => PerformSegue("lockPasswordSegue", this));
|
vm.LogInWithMasterPasswordAction = () => DismissViewController(false, () => PerformSegue("lockPasswordSegue", this));
|
||||||
vm.RequestAdminApprovalAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AdminApproval, vm.Email));
|
vm.RequestAdminApprovalAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AdminApproval, vm.Email, true));
|
||||||
vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AuthenticateAndUnlock, vm.Email));
|
vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AuthenticateAndUnlock, vm.Email, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
var navigationPage = new NavigationPage(loginApproveDevicePage);
|
var navigationPage = new NavigationPage(loginApproveDevicePage);
|
||||||
|
|||||||
@@ -533,11 +533,11 @@ namespace Bit.iOS.Extension
|
|||||||
LogoutIfAuthed();
|
LogoutIfAuthed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LaunchLoginWithDevice(AuthRequestType authRequestType,string email = null)
|
private void LaunchLoginWithDevice(AuthRequestType authRequestType, string email = null, bool authingWithSso = false)
|
||||||
{
|
{
|
||||||
var appOptions = new AppOptions { IosExtension = true };
|
var appOptions = new AppOptions { IosExtension = true };
|
||||||
var app = new App.App(appOptions);
|
var app = new App.App(appOptions);
|
||||||
var loginWithDevicePage = new LoginPasswordlessRequestPage(email, authRequestType, appOptions);
|
var loginWithDevicePage = new LoginPasswordlessRequestPage(email, authRequestType, appOptions, authingWithSso);
|
||||||
ThemeManager.SetTheme(app.Resources);
|
ThemeManager.SetTheme(app.Resources);
|
||||||
ThemeManager.ApplyResourcesTo(loginWithDevicePage);
|
ThemeManager.ApplyResourcesTo(loginWithDevicePage);
|
||||||
if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm)
|
if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm)
|
||||||
@@ -654,8 +654,8 @@ namespace Bit.iOS.Extension
|
|||||||
if (loginApproveDevicePage.BindingContext is LoginApproveDeviceViewModel vm)
|
if (loginApproveDevicePage.BindingContext is LoginApproveDeviceViewModel vm)
|
||||||
{
|
{
|
||||||
vm.LogInWithMasterPasswordAction = () => DismissViewController(false, () => PerformSegue("lockPasswordSegue", this));
|
vm.LogInWithMasterPasswordAction = () => DismissViewController(false, () => PerformSegue("lockPasswordSegue", this));
|
||||||
vm.RequestAdminApprovalAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AdminApproval, vm.Email));
|
vm.RequestAdminApprovalAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AdminApproval, vm.Email, true));
|
||||||
vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AuthenticateAndUnlock, vm.Email));
|
vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AuthenticateAndUnlock, vm.Email, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
var navigationPage = new NavigationPage(loginApproveDevicePage);
|
var navigationPage = new NavigationPage(loginApproveDevicePage);
|
||||||
|
|||||||
@@ -348,9 +348,9 @@ namespace Bit.iOS.ShareExtension
|
|||||||
LogoutIfAuthed();
|
LogoutIfAuthed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LaunchLoginWithDevice(AuthRequestType authRequestType, string email = null)
|
private void LaunchLoginWithDevice(AuthRequestType authRequestType, string email = null, bool authingWithSso = false)
|
||||||
{
|
{
|
||||||
var loginWithDevicePage = new LoginPasswordlessRequestPage(email, authRequestType, _appOptions.Value);
|
var loginWithDevicePage = new LoginPasswordlessRequestPage(email, authRequestType, _appOptions.Value, authingWithSso);
|
||||||
SetupAppAndApplyResources(loginWithDevicePage);
|
SetupAppAndApplyResources(loginWithDevicePage);
|
||||||
if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm)
|
if (loginWithDevicePage.BindingContext is LoginPasswordlessRequestViewModel vm)
|
||||||
{
|
{
|
||||||
@@ -438,8 +438,8 @@ namespace Bit.iOS.ShareExtension
|
|||||||
if (loginApproveDevicePage.BindingContext is LoginApproveDeviceViewModel vm)
|
if (loginApproveDevicePage.BindingContext is LoginApproveDeviceViewModel vm)
|
||||||
{
|
{
|
||||||
vm.LogInWithMasterPasswordAction = () => DismissViewController(false, () => PerformSegue("lockPasswordSegue", this));
|
vm.LogInWithMasterPasswordAction = () => DismissViewController(false, () => PerformSegue("lockPasswordSegue", this));
|
||||||
vm.RequestAdminApprovalAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AdminApproval, vm.Email));
|
vm.RequestAdminApprovalAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AdminApproval, vm.Email, true));
|
||||||
vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AuthenticateAndUnlock, vm.Email));
|
vm.LogInWithDeviceAction = () => DismissViewController(false, () => LaunchLoginWithDevice(AuthRequestType.AuthenticateAndUnlock, vm.Email, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
var navigationPage = new NavigationPage(loginApproveDevicePage);
|
var navigationPage = new NavigationPage(loginApproveDevicePage);
|
||||||
|
|||||||
Reference in New Issue
Block a user