From 990f55b7c58b89812d754b9b177208923230d074 Mon Sep 17 00:00:00 2001 From: Robert Date: Mon, 31 Jul 2023 22:20:45 +0200 Subject: [PATCH] Workshop - Added login UI --- .../CurrentUser/CurrentUserViewModel.cs | 31 ++- .../CurrentUser/WorkshopLoginView.axaml | 36 +++ .../CurrentUser/WorkshopLoginView.axaml.cs | 17 ++ .../CurrentUser/WorkshopLoginViewModel.cs | 85 +++++++ .../Services/IAuthenticationService.cs | 218 +++++++++--------- 5 files changed, 274 insertions(+), 113 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs index c204c3165..388ebe130 100644 --- a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs @@ -5,9 +5,11 @@ using System.Reactive.Disposables; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Services; using Avalonia.Media.Imaging; +using FluentAvalonia.UI.Controls; using Flurl.Http; using ReactiveUI; using Serilog; @@ -16,21 +18,24 @@ namespace Artemis.UI.Screens.Workshop.CurrentUser; public class CurrentUserViewModel : ActivatableViewModelBase { - private readonly ILogger _logger; private readonly IAuthenticationService _authenticationService; - private bool _loading = true; + private readonly ILogger _logger; + private readonly IWindowService _windowService; private Bitmap? _avatar; private string? _email; + private bool _loading = true; private string? _name; private string? _userId; - public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService) + public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService, IWindowService windowService) { _logger = logger; _authenticationService = authenticationService; + _windowService = windowService; Login = ReactiveCommand.CreateFromTask(ExecuteLogin); this.WhenActivated(d => ReactiveCommand.CreateFromTask(ExecuteAutoLogin).Execute().Subscribe().DisposeWith(d)); + this.WhenActivated(d => _authenticationService.IsLoggedIn.Subscribe(_ => LoadCurrentUser().DisposeWith(d))); } public bool Loading @@ -72,8 +77,13 @@ public class CurrentUserViewModel : ActivatableViewModelBase private async Task ExecuteLogin(CancellationToken cancellationToken) { - await _authenticationService.Login(); - await LoadCurrentUser(); + ContentDialogResult result = await _windowService.CreateContentDialog() + .WithViewModel(out WorkshopLoginViewModel _) + .WithTitle("Workshop login") + .ShowAsync(); + + if (result == ContentDialogResult.Primary) + await LoadCurrentUser(); } private async Task ExecuteAutoLogin(CancellationToken cancellationToken) @@ -95,21 +105,26 @@ public class CurrentUserViewModel : ActivatableViewModelBase private async Task LoadCurrentUser() { - if (!_authenticationService.IsLoggedIn) - return; - UserId = _authenticationService.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; Name = _authenticationService.Claims.FirstOrDefault(c => c.Type == "name")?.Value; Email = _authenticationService.Claims.FirstOrDefault(c => c.Type == "email")?.Value; if (UserId != null) + { await LoadAvatar(UserId); + } + else + { + Avatar?.Dispose(); + Avatar = null; + } } private async Task LoadAvatar(string userId) { try { + Avatar?.Dispose(); Avatar = new Bitmap(await $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{userId}".GetStreamAsync()); } catch (Exception) diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml new file mode 100644 index 000000000..81b101233 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml @@ -0,0 +1,36 @@ + + + + In order to login you must enter your Artemis credentials in a browser. + + + If you do not have an account yet you can create one in the browser too. Alternatively you can log in with Google or Discord. + + + + + When logging in please enter the the code below + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs new file mode 100644 index 000000000..064ae6691 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.CurrentUser; + +public partial class WorkshopLoginView : ReactiveUserControl +{ + public WorkshopLoginView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs new file mode 100644 index 000000000..93f86d1af --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.CurrentUser; + +public class WorkshopLoginViewModel : ContentDialogViewModelBase +{ + private readonly IAuthenticationService _authenticationService; + private readonly INotificationService _notificationService; + private readonly CancellationTokenSource _cts; + private ObservableAsPropertyHelper? _deviceCode; + private string? _uri; + + public WorkshopLoginViewModel(IAuthenticationService authenticationService, INotificationService notificationService) + { + _authenticationService = authenticationService; + _notificationService = notificationService; + _cts = new CancellationTokenSource(); + + OpenBrowser = ReactiveCommand.Create(ExecuteOpenBrowser); + CopyToClipboard = ReactiveCommand.CreateFromTask(ExecuteCopyToClipboard); + + this.WhenActivated(d => + { + Dispatcher.UIThread.InvokeAsync(Login); + + _deviceCode = _authenticationService.UserCode + .Select(uc => uc != null ? string.Join("-", string.Join("-", uc.Chunk(3).Select(c => new string(c)))) : null) + .ToProperty(this, vm => vm.Code) + .DisposeWith(d); + _authenticationService.VerificationUri.Subscribe(u => _uri = u).DisposeWith(d); + Disposable.Create(_cts, cts => cts.Cancel()).DisposeWith(d); + }); + } + + public ReactiveCommand OpenBrowser { get; } + public ReactiveCommand CopyToClipboard { get; } + + public string? Code => _deviceCode?.Value; + + private async Task Login() + { + try + { + bool result = await _authenticationService.Login(_cts.Token); + if (result) + _notificationService.CreateNotification().WithTitle("Login succeeded").WithSeverity(NotificationSeverity.Success).Show(); + ContentDialog?.Hide(result ? ContentDialogResult.Primary : ContentDialogResult.Secondary); + } + catch (Exception e) + { + if (e is not TaskCanceledException) + _notificationService.CreateNotification().WithTitle("Login failed").WithMessage(e.Message).WithSeverity(NotificationSeverity.Error).Show(); + ContentDialog?.Hide(ContentDialogResult.Secondary); + } + } + + private void ExecuteOpenBrowser() + { + if (_uri == null) + return; + Utilities.OpenUrl(_uri); + } + + private async Task ExecuteCopyToClipboard() + { + if (Code == null) + return; + await Shared.UI.Clipboard.SetTextAsync(Code); + _notificationService.CreateNotification().WithMessage("Code copied to clipboard.").Show(); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs index eed47ecff..2e22afbba 100644 --- a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; -using System.Diagnostics; using System.IdentityModel.Tokens.Jwt; +using System.Reactive.Linq; +using System.Reactive.Subjects; using System.Security.Claims; using Artemis.Core; using Artemis.Core.Services; @@ -12,29 +13,31 @@ namespace Artemis.WebClient.Workshop.Services; public interface IAuthenticationService : IProtectedArtemisService { - bool IsLoggedIn { get; } - string? UserCode { get; } + IObservable IsLoggedIn { get; } + IObservable UserCode { get; } + IObservable VerificationUri { get; } ReadOnlyObservableCollection Claims { get; } Task GetBearer(); Task AutoLogin(); - Task Login(); + Task Login(CancellationToken cancellationToken); void Logout(); } internal class AuthenticationService : CorePropertyChanged, IAuthenticationService { private const string CLIENT_ID = "artemis.desktop"; + private readonly IAuthenticationRepository _authenticationRepository; + private readonly SemaphoreSlim _bearerLock = new(1); + private readonly ObservableCollection _claims = new(); private readonly IDiscoveryCache _discoveryCache; - private readonly IAuthenticationRepository _authenticationRepository; private readonly IHttpClientFactory _httpClientFactory; - private readonly ObservableCollection _claims = new(); - private readonly SemaphoreSlim _bearerLock = new(1); + private readonly BehaviorSubject _isLoggedInSubject = new(false); + private readonly BehaviorSubject _userCodeSubject = new(null); + private readonly BehaviorSubject _verificationUriSubject = new(null); private AuthenticationToken? _token; - private string? _userCode; - private bool _isLoggedIn; public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) { @@ -45,19 +48,85 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi Claims = new ReadOnlyObservableCollection(_claims); } - /// - public bool IsLoggedIn + private async Task GetDiscovery() { - get => _isLoggedIn; - private set => SetAndNotify(ref _isLoggedIn, value); + DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync(); + if (disco.IsError) + throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error); + + return disco; + } + + private async Task AttemptRequestDeviceToken(HttpClient client, string deviceCode, CancellationToken cancellationToken) + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + TokenResponse response = await client.RequestDeviceTokenAsync(new DeviceTokenRequest + { + Address = disco.TokenEndpoint, + ClientId = CLIENT_ID, + DeviceCode = deviceCode + }, cancellationToken); + + if (response.IsError) + { + if (response.Error == OidcConstants.TokenErrors.AuthorizationPending || response.Error == OidcConstants.TokenErrors.SlowDown) + return false; + + throw new ArtemisWebClientException("Failed to request device token: " + response.Error); + } + + SetCurrentUser(response); + return true; + } + + private void SetCurrentUser(TokenResponse response) + { + _token = new AuthenticationToken(response); + _authenticationRepository.SetRefreshToken(_token.RefreshToken); + + JwtSecurityTokenHandler handler = new(); + JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken); + if (token == null) + throw new ArtemisWebClientException("Failed to read JWT token"); + + _claims.Clear(); + foreach (Claim responseClaim in token.Claims) + _claims.Add(responseClaim); + + _isLoggedInSubject.OnNext(true); + } + + private async Task UseRefreshToken(string refreshToken) + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + HttpClient client = _httpClientFactory.CreateClient(); + TokenResponse response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest + { + Address = disco.TokenEndpoint, + ClientId = CLIENT_ID, + RefreshToken = refreshToken + }); + + if (response.IsError) + { + if (response.Error == OidcConstants.TokenErrors.ExpiredToken) + return false; + + throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error); + } + + SetCurrentUser(response); + return false; } /// - public string? UserCode - { - get => _userCode; - private set => SetAndNotify(ref _userCode, value); - } + public IObservable IsLoggedIn => _isLoggedInSubject.AsObservable(); + + /// + public IObservable UserCode => _userCodeSubject.AsObservable(); + + /// + public IObservable VerificationUri => _verificationUriSubject.AsObservable(); /// public ReadOnlyObservableCollection Claims { get; } @@ -69,7 +138,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi try { // If not logged in, attempt to auto login first - if (!IsLoggedIn) + if (!_isLoggedInSubject.Value) await AutoLogin(); if (_token == null) @@ -90,9 +159,9 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi /// public async Task AutoLogin() { - if (IsLoggedIn) + if (_isLoggedInSubject.Value) return true; - + string? refreshToken = _authenticationRepository.GetRefreshToken(); if (refreshToken == null) return false; @@ -101,34 +170,44 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi } /// - public async Task Login() + public async Task Login(CancellationToken cancellationToken) { DiscoveryDocumentResponse disco = await GetDiscovery(); HttpClient client = _httpClientFactory.CreateClient(); DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest { Address = disco.DeviceAuthorizationEndpoint, - ClientId = CLIENT_ID, + ClientId = CLIENT_ID, Scope = "openid profile email offline_access api" - }); + }, cancellationToken); if (response.IsError) throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error); if (response.DeviceCode == null) throw new ArtemisWebClientException("Failed to request device authorization: Got no device code"); DateTimeOffset timeout = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn ?? 1800); + _userCodeSubject.OnNext(response.UserCode); + _verificationUriSubject.OnNext(response.VerificationUri); - Process.Start(new ProcessStartInfo {FileName = response.VerificationUriComplete, UseShellExecute = true}); - await Task.Delay(TimeSpan.FromSeconds(response.Interval)); - while (DateTimeOffset.UtcNow < timeout) + await Task.Delay(TimeSpan.FromSeconds(response.Interval), cancellationToken); + try { - bool success = await AttemptRequestDeviceToken(client, response.DeviceCode); - if (success) - return true; - await Task.Delay(TimeSpan.FromSeconds(response.Interval)); - } + while (DateTimeOffset.UtcNow < timeout) + { + cancellationToken.ThrowIfCancellationRequested(); + bool success = await AttemptRequestDeviceToken(client, response.DeviceCode, cancellationToken); + if (success) + return true; + await Task.Delay(TimeSpan.FromSeconds(response.Interval), cancellationToken); + } - return false; + return false; + } + finally + { + _userCodeSubject.OnNext(null); + _verificationUriSubject.OnNext(null); + } } /// @@ -137,78 +216,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi _token = null; _claims.Clear(); _authenticationRepository.SetRefreshToken(null); - - IsLoggedIn = false; - } - private async Task GetDiscovery() - { - DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync(); - if (disco.IsError) - throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error); - - return disco; - } - - private async Task AttemptRequestDeviceToken(HttpClient client, string deviceCode) - { - DiscoveryDocumentResponse disco = await GetDiscovery(); - TokenResponse response = await client.RequestDeviceTokenAsync(new DeviceTokenRequest - { - Address = disco.TokenEndpoint, - ClientId = CLIENT_ID, - DeviceCode = deviceCode - }); - - if (response.IsError) - { - if (response.Error == OidcConstants.TokenErrors.AuthorizationPending || response.Error == OidcConstants.TokenErrors.SlowDown) - return false; - - throw new ArtemisWebClientException("Failed to request device token: " + response.Error); - } - - SetCurrentUser(response); - return true; - } - - private void SetCurrentUser(TokenResponse response) - { - _token = new AuthenticationToken(response); - _authenticationRepository.SetRefreshToken(_token.RefreshToken); - - JwtSecurityTokenHandler handler = new(); - JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken); - if (token == null) - throw new ArtemisWebClientException("Failed to read JWT token"); - - _claims.Clear(); - foreach (Claim responseClaim in token.Claims) - _claims.Add(responseClaim); - - IsLoggedIn = true; - } - - private async Task UseRefreshToken(string refreshToken) - { - DiscoveryDocumentResponse disco = await GetDiscovery(); - HttpClient client = _httpClientFactory.CreateClient(); - TokenResponse response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest() - { - Address = disco.TokenEndpoint, - ClientId = CLIENT_ID, - RefreshToken = refreshToken - }); - - if (response.IsError) - { - if (response.Error == OidcConstants.TokenErrors.ExpiredToken) - return false; - - throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error); - } - - SetCurrentUser(response); - return false; + _isLoggedInSubject.OnNext(false); } } \ No newline at end of file