1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2026-02-04 10:53:31 +00:00

Workshop - Added login UI

This commit is contained in:
Robert 2023-07-31 22:20:45 +02:00
parent 7eccdf079a
commit 990f55b7c5
5 changed files with 274 additions and 113 deletions

View File

@ -5,9 +5,11 @@ using System.Reactive.Disposables;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Services; using Artemis.WebClient.Workshop.Services;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using FluentAvalonia.UI.Controls;
using Flurl.Http; using Flurl.Http;
using ReactiveUI; using ReactiveUI;
using Serilog; using Serilog;
@ -16,21 +18,24 @@ namespace Artemis.UI.Screens.Workshop.CurrentUser;
public class CurrentUserViewModel : ActivatableViewModelBase public class CurrentUserViewModel : ActivatableViewModelBase
{ {
private readonly ILogger _logger;
private readonly IAuthenticationService _authenticationService; private readonly IAuthenticationService _authenticationService;
private bool _loading = true; private readonly ILogger _logger;
private readonly IWindowService _windowService;
private Bitmap? _avatar; private Bitmap? _avatar;
private string? _email; private string? _email;
private bool _loading = true;
private string? _name; private string? _name;
private string? _userId; private string? _userId;
public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService) public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService, IWindowService windowService)
{ {
_logger = logger; _logger = logger;
_authenticationService = authenticationService; _authenticationService = authenticationService;
_windowService = windowService;
Login = ReactiveCommand.CreateFromTask(ExecuteLogin); Login = ReactiveCommand.CreateFromTask(ExecuteLogin);
this.WhenActivated(d => ReactiveCommand.CreateFromTask(ExecuteAutoLogin).Execute().Subscribe().DisposeWith(d)); this.WhenActivated(d => ReactiveCommand.CreateFromTask(ExecuteAutoLogin).Execute().Subscribe().DisposeWith(d));
this.WhenActivated(d => _authenticationService.IsLoggedIn.Subscribe(_ => LoadCurrentUser().DisposeWith(d)));
} }
public bool Loading public bool Loading
@ -72,8 +77,13 @@ public class CurrentUserViewModel : ActivatableViewModelBase
private async Task ExecuteLogin(CancellationToken cancellationToken) private async Task ExecuteLogin(CancellationToken cancellationToken)
{ {
await _authenticationService.Login(); ContentDialogResult result = await _windowService.CreateContentDialog()
await LoadCurrentUser(); .WithViewModel(out WorkshopLoginViewModel _)
.WithTitle("Workshop login")
.ShowAsync();
if (result == ContentDialogResult.Primary)
await LoadCurrentUser();
} }
private async Task ExecuteAutoLogin(CancellationToken cancellationToken) private async Task ExecuteAutoLogin(CancellationToken cancellationToken)
@ -95,21 +105,26 @@ public class CurrentUserViewModel : ActivatableViewModelBase
private async Task LoadCurrentUser() private async Task LoadCurrentUser()
{ {
if (!_authenticationService.IsLoggedIn)
return;
UserId = _authenticationService.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; UserId = _authenticationService.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
Name = _authenticationService.Claims.FirstOrDefault(c => c.Type == "name")?.Value; Name = _authenticationService.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
Email = _authenticationService.Claims.FirstOrDefault(c => c.Type == "email")?.Value; Email = _authenticationService.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
if (UserId != null) if (UserId != null)
{
await LoadAvatar(UserId); await LoadAvatar(UserId);
}
else
{
Avatar?.Dispose();
Avatar = null;
}
} }
private async Task LoadAvatar(string userId) private async Task LoadAvatar(string userId)
{ {
try try
{ {
Avatar?.Dispose();
Avatar = new Bitmap(await $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{userId}".GetStreamAsync()); Avatar = new Bitmap(await $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{userId}".GetStreamAsync());
} }
catch (Exception) catch (Exception)

View File

@ -0,0 +1,36 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:currentUser="clr-namespace:Artemis.UI.Screens.Workshop.CurrentUser"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.CurrentUser.WorkshopLoginView"
x:DataType="currentUser:WorkshopLoginViewModel">
<StackPanel Width="400">
<TextBlock TextWrapping="Wrap">
In order to login you must enter your Artemis credentials in a browser.
</TextBlock>
<TextBlock TextWrapping="Wrap" Margin="0 15">
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.
</TextBlock>
<ProgressBar IsIndeterminate="True" IsVisible="{CompiledBinding Code, Converter={x:Static StringConverters.IsNullOrEmpty}}"></ProgressBar>
<StackPanel IsVisible="{CompiledBinding Code, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock TextWrapping="Wrap">When logging in please enter the the code below</TextBlock>
<StackPanel Margin="0 10" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5">
<TextBox Width="115" IsReadOnly="True" Text="{CompiledBinding Code}" />
<Button Width="32" Height="32" ToolTip.Tip="Copy code to clipboard" Command="{CompiledBinding CopyToClipboard}">
<avalonia:MaterialIcon Kind="ContentCopy"/>
</Button>
</StackPanel>
<Button Width="152" HorizontalAlignment="Center" Command="{CompiledBinding OpenBrowser}">
<StackPanel Orientation="Horizontal" Spacing="5">
<avalonia:MaterialIcon Kind="ExternalLink" />
<TextBlock>Open browser</TextBlock>
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,17 @@
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.CurrentUser;
public partial class WorkshopLoginView : ReactiveUserControl<WorkshopLoginViewModel>
{
public WorkshopLoginView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@ -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<string?>? _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<Unit, Unit> OpenBrowser { get; }
public ReactiveCommand<Unit, Unit> 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();
}
}

View File

@ -1,6 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Security.Claims; using System.Security.Claims;
using Artemis.Core; using Artemis.Core;
using Artemis.Core.Services; using Artemis.Core.Services;
@ -12,29 +13,31 @@ namespace Artemis.WebClient.Workshop.Services;
public interface IAuthenticationService : IProtectedArtemisService public interface IAuthenticationService : IProtectedArtemisService
{ {
bool IsLoggedIn { get; } IObservable<bool> IsLoggedIn { get; }
string? UserCode { get; } IObservable<string?> UserCode { get; }
IObservable<string?> VerificationUri { get; }
ReadOnlyObservableCollection<Claim> Claims { get; } ReadOnlyObservableCollection<Claim> Claims { get; }
Task<string?> GetBearer(); Task<string?> GetBearer();
Task<bool> AutoLogin(); Task<bool> AutoLogin();
Task<bool> Login(); Task<bool> Login(CancellationToken cancellationToken);
void Logout(); void Logout();
} }
internal class AuthenticationService : CorePropertyChanged, IAuthenticationService internal class AuthenticationService : CorePropertyChanged, IAuthenticationService
{ {
private const string CLIENT_ID = "artemis.desktop"; private const string CLIENT_ID = "artemis.desktop";
private readonly IAuthenticationRepository _authenticationRepository;
private readonly SemaphoreSlim _bearerLock = new(1);
private readonly ObservableCollection<Claim> _claims = new();
private readonly IDiscoveryCache _discoveryCache; private readonly IDiscoveryCache _discoveryCache;
private readonly IAuthenticationRepository _authenticationRepository;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ObservableCollection<Claim> _claims = new(); private readonly BehaviorSubject<bool> _isLoggedInSubject = new(false);
private readonly SemaphoreSlim _bearerLock = new(1); private readonly BehaviorSubject<string?> _userCodeSubject = new(null);
private readonly BehaviorSubject<string?> _verificationUriSubject = new(null);
private AuthenticationToken? _token; private AuthenticationToken? _token;
private string? _userCode;
private bool _isLoggedIn;
public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
{ {
@ -45,19 +48,85 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
Claims = new ReadOnlyObservableCollection<Claim>(_claims); Claims = new ReadOnlyObservableCollection<Claim>(_claims);
} }
/// <inheritdoc /> private async Task<DiscoveryDocumentResponse> GetDiscovery()
public bool IsLoggedIn
{ {
get => _isLoggedIn; DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync();
private set => SetAndNotify(ref _isLoggedIn, value); if (disco.IsError)
throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error);
return disco;
}
private async Task<bool> 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<bool> 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;
} }
/// <inheritdoc /> /// <inheritdoc />
public string? UserCode public IObservable<bool> IsLoggedIn => _isLoggedInSubject.AsObservable();
{
get => _userCode; /// <inheritdoc />
private set => SetAndNotify(ref _userCode, value); public IObservable<string?> UserCode => _userCodeSubject.AsObservable();
}
/// <inheritdoc />
public IObservable<string?> VerificationUri => _verificationUriSubject.AsObservable();
/// <inheritdoc /> /// <inheritdoc />
public ReadOnlyObservableCollection<Claim> Claims { get; } public ReadOnlyObservableCollection<Claim> Claims { get; }
@ -69,7 +138,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
try try
{ {
// If not logged in, attempt to auto login first // If not logged in, attempt to auto login first
if (!IsLoggedIn) if (!_isLoggedInSubject.Value)
await AutoLogin(); await AutoLogin();
if (_token == null) if (_token == null)
@ -90,9 +159,9 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> AutoLogin() public async Task<bool> AutoLogin()
{ {
if (IsLoggedIn) if (_isLoggedInSubject.Value)
return true; return true;
string? refreshToken = _authenticationRepository.GetRefreshToken(); string? refreshToken = _authenticationRepository.GetRefreshToken();
if (refreshToken == null) if (refreshToken == null)
return false; return false;
@ -101,34 +170,44 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> Login() public async Task<bool> Login(CancellationToken cancellationToken)
{ {
DiscoveryDocumentResponse disco = await GetDiscovery(); DiscoveryDocumentResponse disco = await GetDiscovery();
HttpClient client = _httpClientFactory.CreateClient(); HttpClient client = _httpClientFactory.CreateClient();
DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
{ {
Address = disco.DeviceAuthorizationEndpoint, Address = disco.DeviceAuthorizationEndpoint,
ClientId = CLIENT_ID, ClientId = CLIENT_ID,
Scope = "openid profile email offline_access api" Scope = "openid profile email offline_access api"
}); }, cancellationToken);
if (response.IsError) if (response.IsError)
throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error); throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error);
if (response.DeviceCode == null) if (response.DeviceCode == null)
throw new ArtemisWebClientException("Failed to request device authorization: Got no device code"); throw new ArtemisWebClientException("Failed to request device authorization: Got no device code");
DateTimeOffset timeout = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn ?? 1800); 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), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(response.Interval)); try
while (DateTimeOffset.UtcNow < timeout)
{ {
bool success = await AttemptRequestDeviceToken(client, response.DeviceCode); while (DateTimeOffset.UtcNow < timeout)
if (success) {
return true; cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(TimeSpan.FromSeconds(response.Interval)); 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);
}
} }
/// <inheritdoc /> /// <inheritdoc />
@ -137,78 +216,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
_token = null; _token = null;
_claims.Clear(); _claims.Clear();
_authenticationRepository.SetRefreshToken(null); _authenticationRepository.SetRefreshToken(null);
IsLoggedIn = false;
}
private async Task<DiscoveryDocumentResponse> GetDiscovery() _isLoggedInSubject.OnNext(false);
{
DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync();
if (disco.IsError)
throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error);
return disco;
}
private async Task<bool> 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<bool> 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;
} }
} }