1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-12 13:28:33 +00:00

Added basic login

This commit is contained in:
Robert 2023-06-22 22:56:24 +02:00
parent bde4e861c2
commit 4224875fb0
14 changed files with 524 additions and 5 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@ -56,8 +56,8 @@
Grid.Column="0"
Grid.RowSpan="3"
VerticalAlignment="Top"
Height="75"
Width="75"
Height="40"
Width="40"
Margin="0 0 15 0"
IsVisible="{CompiledBinding RobertProfileImage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
RenderOptions.BitmapInterpolationMode="HighQuality">

View File

@ -0,0 +1,59 @@
<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:currentUser="clr-namespace:Artemis.UI.Screens.Workshop.CurrentUser"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.CurrentUser.CurrentUserView"
x:DataType="currentUser:CurrentUserViewModel">
<Panel>
<!-- Signed out -->
<Ellipse Height="28" Width="28" IsVisible="{CompiledBinding !IsLoggedIn}">
<Ellipse.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Login" Command="{CompiledBinding Login}">
<MenuItem.Icon>
<avalonia:MaterialIcon Kind="Login" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Ellipse.ContextFlyout>
<Ellipse.Fill>
<ImageBrush Source="/Assets/Images/avatar-placeholder.png" />
</Ellipse.Fill>
</Ellipse>
<!-- Signed in -->
<Ellipse Height="28" Width="28" IsVisible="{CompiledBinding IsLoggedIn}" Name="UserMenu">
<Ellipse.ContextFlyout>
<Flyout>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="*,*,*" MinWidth="300">
<Ellipse Grid.Column="0" Grid.RowSpan="3" Height="50" Width="50" Margin="0 0 8 0" VerticalAlignment="Top">
<Ellipse.Fill>
<ImageBrush Source="{CompiledBinding Avatar}" />
</Ellipse.Fill>
</Ellipse>
<TextBlock Grid.Column="1" Grid.Row="0" Text="{CompiledBinding Name}" Margin="0 4 0 0"></TextBlock>
<TextBlock Grid.Column="1" Grid.Row="1" Text="{CompiledBinding Email}"></TextBlock>
<controls:HyperlinkButton
Grid.Column="1"
Grid.Row="2"
Margin="-8 0 0 0"
Padding="6 4"
Click="Button_OnClick">
Sign out
</controls:HyperlinkButton>
</Grid>
</Flyout>
</Ellipse.ContextFlyout>
<Ellipse.Fill>
<ImageBrush Source="{CompiledBinding Avatar}" />
</Ellipse.Fill>
</Ellipse>
</Panel>
</UserControl>

View File

@ -0,0 +1,21 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.CurrentUser;
public partial class CurrentUserView : ReactiveUserControl<CurrentUserViewModel>
{
public CurrentUserView()
{
InitializeComponent();
}
private void Button_OnClick(object? sender, RoutedEventArgs e)
{
UserMenu.ContextFlyout?.Hide();
ViewModel?.Logout();
}
}

View File

@ -0,0 +1,104 @@
using System;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Shared;
using Artemis.WebClient.Workshop.Services;
using Avalonia.Media.Imaging;
using Flurl.Http;
using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.CurrentUser;
public class CurrentUserViewModel : ActivatableViewModelBase
{
private readonly IAuthenticationService _authenticationService;
private ObservableAsPropertyHelper<bool>? _isLoggedIn;
private string? _userId;
private string? _name;
private string? _email;
private Bitmap? _avatar;
public CurrentUserViewModel(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
Login = ReactiveCommand.CreateFromTask(ExecuteLogin);
this.WhenActivated(d => _isLoggedIn = _authenticationService.WhenAnyValue(s => s.IsLoggedIn).ToProperty(this, vm => vm.IsLoggedIn).DisposeWith(d));
this.WhenActivated(d =>
{
Task.Run(async () =>
{
await _authenticationService.AutoLogin();
await LoadCurrentUser();
}).DisposeWith(d);
});
}
public void Logout()
{
_authenticationService.Logout();
}
public string? UserId
{
get => _userId;
set => RaiseAndSetIfChanged(ref _userId, value);
}
public string? Name
{
get => _name;
set => RaiseAndSetIfChanged(ref _name, value);
}
public string? Email
{
get => _email;
set => RaiseAndSetIfChanged(ref _email, value);
}
public Bitmap? Avatar
{
get => _avatar;
set => RaiseAndSetIfChanged(ref _avatar, value);
}
public ReactiveCommand<Unit, Unit> Login { get; }
public bool IsLoggedIn => _isLoggedIn?.Value ?? false;
private async Task ExecuteLogin(CancellationToken cancellationToken)
{
await _authenticationService.Login();
await LoadCurrentUser();
Console.WriteLine(_authenticationService.Claims);
}
private async Task LoadCurrentUser()
{
if (!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);
}
private async Task LoadAvatar(string userId)
{
try
{
Avatar = new Bitmap(await $"{IAuthenticationService.AUTHORITY}/user/avatar/{userId}".GetStreamAsync());
}
catch (Exception)
{
// ignored
}
}
}

View File

@ -30,6 +30,7 @@
</ListBox.ItemTemplate>
</ListBox>
</Border>
<ContentControl Grid.Column="1" VerticalAlignment="Top" HorizontalAlignment="Right" Content="{CompiledBinding CurrentUserViewModel}" Margin="8"></ContentControl>
</Grid>
</Border>
</UserControl>

View File

@ -4,6 +4,7 @@ using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.UI.Screens.Workshop.CurrentUser;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Artemis.WebClient.Workshop;
@ -18,8 +19,9 @@ public class WorkshopViewModel : MainScreenViewModel
{
private readonly IWorkshopClient _workshopClient;
public WorkshopViewModel(IScreen hostScreen, IWorkshopClient workshopClient) : base(hostScreen, "workshop")
public WorkshopViewModel(IScreen hostScreen, IWorkshopClient workshopClient, CurrentUserViewModel currentUserViewModel) : base(hostScreen, "workshop")
{
CurrentUserViewModel = currentUserViewModel;
_workshopClient = workshopClient;
DisplayName = "Workshop";
@ -27,7 +29,8 @@ public class WorkshopViewModel : MainScreenViewModel
}
public ObservableCollection<IGetEntries_Entries_Nodes> Test { get; set; } = new();
public CurrentUserViewModel CurrentUserViewModel { get; set; }
private async Task GetEntries()
{

View File

@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.0" />
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageReference Include="IdentityModel" Version="6.1.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="StrawberryShake.Server" Version="13.0.5" />
@ -17,4 +18,8 @@
<ItemGroup>
<None Remove=".graphqlconfig" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Artemis.Core\Artemis.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -1,5 +1,8 @@
using Artemis.WebClient.Workshop.Repositories;
using Artemis.WebClient.Workshop.Services;
using DryIoc;
using DryIoc.Microsoft.DependencyInjection;
using IdentityModel.Client;
using Microsoft.Extensions.DependencyInjection;
namespace Artemis.WebClient.Workshop.DryIoc;
@ -20,7 +23,16 @@ public static class ContainerExtensions
.AddHttpClient()
.AddWorkshopClient()
.ConfigureHttpClient(client => client.BaseAddress = new Uri("https://localhost:7281/graphql"));
serviceCollection.AddSingleton<IDiscoveryCache>(r =>
{
IHttpClientFactory factory = r.GetRequiredService<IHttpClientFactory>();
return new DiscoveryCache(IAuthenticationService.AUTHORITY, () => factory.CreateClient());
});
container.WithDependencyInjectionAdapter(serviceCollection);
container.Register<IAuthenticationRepository, AuthenticationRepository>(Reuse.Singleton);
container.Register<IAuthenticationService, AuthenticationService>(Reuse.Singleton);
}
}

View File

@ -0,0 +1,7 @@
namespace Artemis.WebClient.Workshop.Entities;
public class RefreshTokenEntity
{
public string RefreshToken { get; set; }
}

View File

@ -0,0 +1,24 @@
using System;
namespace Artemis.Core;
/// <summary>
/// An exception thrown when a web client related error occurs
/// </summary>
public class ArtemisWebClientException : Exception
{
/// <inheritdoc />
public ArtemisWebClientException()
{
}
/// <inheritdoc />
public ArtemisWebClientException(string? message) : base(message)
{
}
/// <inheritdoc />
public ArtemisWebClientException(string? message, Exception? innerException) : base(message, innerException)
{
}
}

View File

@ -0,0 +1,36 @@
using Artemis.WebClient.Workshop.Entities;
using LiteDB;
namespace Artemis.WebClient.Workshop.Repositories;
internal class AuthenticationRepository : IAuthenticationRepository
{
private readonly LiteRepository _repository;
public AuthenticationRepository(LiteRepository repository)
{
_repository = repository;
_repository.Database.GetCollection<RefreshTokenEntity>().EnsureIndex(s => s.RefreshToken);
}
/// <inheritdoc />
public void SetRefreshToken(string? refreshToken)
{
_repository.Database.GetCollection<RefreshTokenEntity>().DeleteAll();
if (refreshToken != null)
_repository.Insert(new RefreshTokenEntity {RefreshToken = refreshToken});
}
/// <inheritdoc />
public string? GetRefreshToken()
{
return _repository.Query<RefreshTokenEntity>().FirstOrDefault()?.RefreshToken;
}
}
internal interface IAuthenticationRepository
{
void SetRefreshToken(string? refreshToken);
string? GetRefreshToken();
}

View File

@ -0,0 +1,25 @@
using Artemis.Core;
using IdentityModel.Client;
namespace Artemis.WebClient.Workshop.Services;
internal class AuthenticationToken
{
public AuthenticationToken(TokenResponse tokenResponse)
{
if (tokenResponse.AccessToken == null)
throw new ArtemisWebClientException("Token response contains no access token");
if (tokenResponse.RefreshToken == null)
throw new ArtemisWebClientException("Token response contains no refresh token");
AccessToken = tokenResponse.AccessToken;
RefreshToken = tokenResponse.RefreshToken;
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn);
}
public DateTimeOffset ExpiresAt { get; private set; }
public bool Expired => DateTimeOffset.UtcNow.AddSeconds(5) >= ExpiresAt;
public string AccessToken { get; private set; }
public string RefreshToken { get; private set; }
}

View File

@ -0,0 +1,222 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Security.Claims;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.WebClient.Workshop.Repositories;
using IdentityModel;
using IdentityModel.Client;
namespace Artemis.WebClient.Workshop.Services;
public interface IAuthenticationService : IProtectedArtemisService
{
public const string AUTHORITY = "https://localhost:5001";
bool IsLoggedIn { get; }
string? UserCode { get; }
ReadOnlyObservableCollection<Claim> Claims { get; }
Task<string?> GetBearer();
Task<bool> AutoLogin();
Task<bool> Login();
void Logout();
}
internal class AuthenticationService : CorePropertyChanged, IAuthenticationService
{
internal const string CLIENT_ID = "artemis.desktop";
private readonly IDiscoveryCache _discoveryCache;
private readonly IAuthenticationRepository _authenticationRepository;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ObservableCollection<Claim> _claims = new();
private readonly SemaphoreSlim _bearerLock = new(1);
private AuthenticationToken? _token;
private string? _userCode;
private bool _isLoggedIn;
public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
{
_httpClientFactory = httpClientFactory;
_discoveryCache = discoveryCache;
_authenticationRepository = authenticationRepository;
Claims = new ReadOnlyObservableCollection<Claim>(_claims);
}
/// <inheritdoc />
public bool IsLoggedIn
{
get => _isLoggedIn;
private set => SetAndNotify(ref _isLoggedIn, value);
}
/// <inheritdoc />
public string? UserCode
{
get => _userCode;
private set => SetAndNotify(ref _userCode, value);
}
/// <inheritdoc />
public ReadOnlyObservableCollection<Claim> Claims { get; }
public async Task<string?> GetBearer()
{
await _bearerLock.WaitAsync();
try
{
// If not logged in, attempt to auto login first
if (!IsLoggedIn)
await AutoLogin();
if (_token == null)
return null;
// If the token is expiring, refresh it
if (_token.Expired && !await UseRefreshToken(_token.RefreshToken))
return null;
return _token.AccessToken;
}
finally
{
_bearerLock.Release();
}
}
/// <inheritdoc />
public async Task<bool> AutoLogin()
{
if (IsLoggedIn)
return true;
string? refreshToken = _authenticationRepository.GetRefreshToken();
if (refreshToken == null)
return false;
return await UseRefreshToken(refreshToken);
}
/// <inheritdoc />
public async Task<bool> Login()
{
DiscoveryDocumentResponse disco = await GetDiscovery();
HttpClient client = _httpClientFactory.CreateClient();
DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
{
Address = disco.DeviceAuthorizationEndpoint,
ClientId = CLIENT_ID
});
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);
Process.Start(new ProcessStartInfo {FileName = response.VerificationUriComplete, UseShellExecute = true});
await Task.Delay(TimeSpan.FromSeconds(response.Interval));
while (DateTimeOffset.UtcNow < timeout)
{
bool success = await AttemptRequestDeviceToken(client, response.DeviceCode);
if (success)
return true;
await Task.Delay(TimeSpan.FromSeconds(response.Interval));
}
return false;
}
/// <inheritdoc />
public void Logout()
{
_token = null;
_claims.Clear();
_authenticationRepository.SetRefreshToken(null);
IsLoggedIn = false;
}
private async Task<DiscoveryDocumentResponse> GetDiscovery()
{
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);
}
await SetCurrentUser(client, response);
return true;
}
private async Task SetCurrentUser(HttpClient client, TokenResponse response)
{
_token = new AuthenticationToken(response);
_authenticationRepository.SetRefreshToken(_token.RefreshToken);
await GetUserInfo(client, _token.AccessToken);
IsLoggedIn = true;
}
private async Task GetUserInfo(HttpClient client, string accessToken)
{
DiscoveryDocumentResponse disco = await GetDiscovery();
UserInfoResponse response = await client.GetUserInfoAsync(new UserInfoRequest()
{
Address = disco.UserInfoEndpoint,
Token = accessToken
});
if (response.IsError)
throw new ArtemisWebClientException("Failed to retrieve user info: " + response.Error);
_claims.Clear();
foreach (Claim responseClaim in response.Claims)
_claims.Add(responseClaim);
}
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);
}
await SetCurrentUser(client, response);
return false;
}
}