1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +00:00

Workshop login - Use code auth flow instead of device code

This commit is contained in:
Robert 2023-08-06 22:05:34 +02:00
parent 990f55b7c5
commit eeaf3999cf
4 changed files with 240 additions and 265 deletions

View File

@ -2,7 +2,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 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" xmlns:currentUser="clr-namespace:Artemis.UI.Screens.Workshop.CurrentUser"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.CurrentUser.WorkshopLoginView" x:Class="Artemis.UI.Screens.Workshop.CurrentUser.WorkshopLoginView"
@ -15,22 +14,7 @@
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. 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> </TextBlock>
<ProgressBar IsIndeterminate="True" IsVisible="{CompiledBinding Code, Converter={x:Static StringConverters.IsNullOrEmpty}}"></ProgressBar> <TextBlock>Waiting for login...</TextBlock>
<StackPanel IsVisible="{CompiledBinding Code, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> <ProgressBar Margin="0 5 0 0" IsIndeterminate="True"></ProgressBar>
<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> </StackPanel>
</UserControl> </UserControl>

View File

@ -1,11 +1,7 @@
using System; using System;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables; using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.Core;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders; using Artemis.UI.Shared.Services.Builders;
@ -19,10 +15,8 @@ namespace Artemis.UI.Screens.Workshop.CurrentUser;
public class WorkshopLoginViewModel : ContentDialogViewModelBase public class WorkshopLoginViewModel : ContentDialogViewModelBase
{ {
private readonly IAuthenticationService _authenticationService; private readonly IAuthenticationService _authenticationService;
private readonly INotificationService _notificationService;
private readonly CancellationTokenSource _cts; private readonly CancellationTokenSource _cts;
private ObservableAsPropertyHelper<string?>? _deviceCode; private readonly INotificationService _notificationService;
private string? _uri;
public WorkshopLoginViewModel(IAuthenticationService authenticationService, INotificationService notificationService) public WorkshopLoginViewModel(IAuthenticationService authenticationService, INotificationService notificationService)
{ {
@ -30,35 +24,20 @@ public class WorkshopLoginViewModel : ContentDialogViewModelBase
_notificationService = notificationService; _notificationService = notificationService;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
OpenBrowser = ReactiveCommand.Create(ExecuteOpenBrowser);
CopyToClipboard = ReactiveCommand.CreateFromTask(ExecuteCopyToClipboard);
this.WhenActivated(d => this.WhenActivated(d =>
{ {
Dispatcher.UIThread.InvokeAsync(Login); 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); 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() private async Task Login()
{ {
try try
{ {
bool result = await _authenticationService.Login(_cts.Token); await _authenticationService.Login(_cts.Token);
if (result) _notificationService.CreateNotification().WithTitle("Login succeeded").WithSeverity(NotificationSeverity.Success).Show();
_notificationService.CreateNotification().WithTitle("Login succeeded").WithSeverity(NotificationSeverity.Success).Show(); ContentDialog?.Hide(ContentDialogResult.Primary);
ContentDialog?.Hide(result ? ContentDialogResult.Primary : ContentDialogResult.Secondary);
} }
catch (Exception e) catch (Exception e)
{ {
@ -67,19 +46,4 @@ public class WorkshopLoginViewModel : ContentDialogViewModelBase
ContentDialog?.Hide(ContentDialogResult.Secondary); 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

@ -0,0 +1,233 @@
using System.Collections.ObjectModel;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Artemis.Core;
using Artemis.WebClient.Workshop.Repositories;
using IdentityModel;
using IdentityModel.Client;
namespace Artemis.WebClient.Workshop.Services;
internal class AuthenticationService : CorePropertyChanged, IAuthenticationService
{
private const string CLIENT_ID = "artemis.desktop";
private readonly IAuthenticationRepository _authenticationRepository;
private readonly SemaphoreSlim _authLock = new(1);
private readonly ObservableCollection<Claim> _claims = new();
private readonly IDiscoveryCache _discoveryCache;
private readonly IHttpClientFactory _httpClientFactory;
private readonly BehaviorSubject<bool> _isLoggedInSubject = new(false);
private AuthenticationToken? _token;
public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
{
_httpClientFactory = httpClientFactory;
_discoveryCache = discoveryCache;
_authenticationRepository = authenticationRepository;
Claims = new ReadOnlyObservableCollection<Claim>(_claims);
}
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 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;
}
private static byte[] HashSha256(string inputString)
{
using SHA256 sha256 = SHA256.Create();
return sha256.ComputeHash(Encoding.UTF8.GetBytes(inputString));
}
/// <inheritdoc />
public IObservable<bool> IsLoggedIn => _isLoggedInSubject.AsObservable();
/// <inheritdoc />
public ReadOnlyObservableCollection<Claim> Claims { get; }
public async Task<string?> GetBearer()
{
await _authLock.WaitAsync();
try
{
// If not logged in, attempt to auto login first
if (!_isLoggedInSubject.Value)
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
{
_authLock.Release();
}
}
/// <inheritdoc />
public async Task<bool> AutoLogin()
{
await _authLock.WaitAsync();
try
{
if (_isLoggedInSubject.Value)
return true;
string? refreshToken = _authenticationRepository.GetRefreshToken();
if (refreshToken == null)
return false;
return await UseRefreshToken(refreshToken);
}
finally
{
_authLock.Release();
}
}
/// <inheritdoc />
public async Task Login(CancellationToken cancellationToken)
{
await _authLock.WaitAsync(cancellationToken);
try
{
if (_isLoggedInSubject.Value)
return;
// Start a HTTP listener, this port could be in use but chances are very slim
string redirectUri = "http://localhost:56789";
using HttpListener listener = new();
listener.Prefixes.Add(redirectUri + "/");
listener.Start();
// Discover the Identity endpoint
DiscoveryDocumentResponse disco = await GetDiscovery();
if (disco.AuthorizeEndpoint == null)
throw new ArtemisWebClientException("Could not determine the authorize endpoint");
// Generate the PKCE code verifier and code challenge
string codeVerifier = CryptoRandom.CreateUniqueId();
string codeChallenge = Base64Url.Encode(HashSha256(codeVerifier));
string state = Guid.NewGuid().ToString("N");
// Open the web browser for the user to log in and authorize the app
RequestUrl authRequestUrl = new(disco.AuthorizeEndpoint);
string url = authRequestUrl.CreateAuthorizeUrl(
CLIENT_ID,
"code",
"openid profile email offline_access api",
redirectUri,
state,
codeChallenge: codeChallenge,
codeChallengeMethod: OidcConstants.CodeChallengeMethods.Sha256);
Utilities.OpenUrl(url);
// Wait for the callback with the code
HttpListenerContext context = await listener.GetContextAsync().WaitAsync(cancellationToken);
string? code = context.Request.QueryString.Get("code");
string? returnState = context.Request.QueryString.Get("state");
// Validate that a code was given and that our state matches, ensuring we don't respond to a request we did not initialize
if (code == null || returnState != state)
throw new ArtemisWebClientException("Did not get the expected response on the callback");
// Redirect the browser to a page hosted by Identity indicating success
context.Response.StatusCode = (int) HttpStatusCode.Redirect;
context.Response.AddHeader("Location", $"{WorkshopConstants.AUTHORITY_URL}/account/login/success");
context.Response.Close();
// Request auth tokens
HttpClient client = _httpClientFactory.CreateClient();
TokenResponse response = await client.RequestAuthorizationCodeTokenAsync(new AuthorizationCodeTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = CLIENT_ID,
Code = code,
CodeVerifier = codeVerifier,
RedirectUri = redirectUri
}, cancellationToken);
if (response.IsError)
throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error);
// Set the current user using the new tokens
SetCurrentUser(response);
listener.Stop();
listener.Close();
}
finally
{
_authLock.Release();
}
}
/// <inheritdoc />
public void Logout()
{
_token = null;
_claims.Clear();
_authenticationRepository.SetRefreshToken(null);
_isLoggedInSubject.OnNext(false);
}
}

View File

@ -1,222 +1,16 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
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.Services; using Artemis.Core.Services;
using Artemis.WebClient.Workshop.Repositories;
using IdentityModel;
using IdentityModel.Client;
namespace Artemis.WebClient.Workshop.Services; namespace Artemis.WebClient.Workshop.Services;
public interface IAuthenticationService : IProtectedArtemisService public interface IAuthenticationService : IProtectedArtemisService
{ {
IObservable<bool> IsLoggedIn { get; } IObservable<bool> IsLoggedIn { 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(CancellationToken cancellationToken); Task Login(CancellationToken cancellationToken);
void Logout(); 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<Claim> _claims = new();
private readonly IDiscoveryCache _discoveryCache;
private readonly IHttpClientFactory _httpClientFactory;
private readonly BehaviorSubject<bool> _isLoggedInSubject = new(false);
private readonly BehaviorSubject<string?> _userCodeSubject = new(null);
private readonly BehaviorSubject<string?> _verificationUriSubject = new(null);
private AuthenticationToken? _token;
public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
{
_httpClientFactory = httpClientFactory;
_discoveryCache = discoveryCache;
_authenticationRepository = authenticationRepository;
Claims = new ReadOnlyObservableCollection<Claim>(_claims);
}
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, 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 />
public IObservable<bool> IsLoggedIn => _isLoggedInSubject.AsObservable();
/// <inheritdoc />
public IObservable<string?> UserCode => _userCodeSubject.AsObservable();
/// <inheritdoc />
public IObservable<string?> VerificationUri => _verificationUriSubject.AsObservable();
/// <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 (!_isLoggedInSubject.Value)
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 (_isLoggedInSubject.Value)
return true;
string? refreshToken = _authenticationRepository.GetRefreshToken();
if (refreshToken == null)
return false;
return await UseRefreshToken(refreshToken);
}
/// <inheritdoc />
public async Task<bool> Login(CancellationToken cancellationToken)
{
DiscoveryDocumentResponse disco = await GetDiscovery();
HttpClient client = _httpClientFactory.CreateClient();
DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
{
Address = disco.DeviceAuthorizationEndpoint,
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);
await Task.Delay(TimeSpan.FromSeconds(response.Interval), cancellationToken);
try
{
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;
}
finally
{
_userCodeSubject.OnNext(null);
_verificationUriSubject.OnNext(null);
}
}
/// <inheritdoc />
public void Logout()
{
_token = null;
_claims.Clear();
_authenticationRepository.SetRefreshToken(null);
_isLoggedInSubject.OnNext(false);
}
} }