diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml index 81b101233..0beec4c69 100644 --- a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml @@ -2,7 +2,6 @@ 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" @@ -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. - - - When logging in please enter the the code below - - - - - - - + Waiting for login... + \ 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 index 93f86d1af..793fedc6d 100644 --- a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs @@ -1,11 +1,7 @@ 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; @@ -19,10 +15,8 @@ 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; + private readonly INotificationService _notificationService; public WorkshopLoginViewModel(IAuthenticationService authenticationService, INotificationService notificationService) { @@ -30,35 +24,20 @@ public class WorkshopLoginViewModel : ContentDialogViewModelBase _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); + await _authenticationService.Login(_cts.Token); + _notificationService.CreateNotification().WithTitle("Login succeeded").WithSeverity(NotificationSeverity.Success).Show(); + ContentDialog?.Hide(ContentDialogResult.Primary); } catch (Exception e) { @@ -67,19 +46,4 @@ public class WorkshopLoginViewModel : ContentDialogViewModelBase 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/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs new file mode 100644 index 000000000..adec69370 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs @@ -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 _claims = new(); + + private readonly IDiscoveryCache _discoveryCache; + private readonly IHttpClientFactory _httpClientFactory; + private readonly BehaviorSubject _isLoggedInSubject = new(false); + + private AuthenticationToken? _token; + + public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) + { + _httpClientFactory = httpClientFactory; + _discoveryCache = discoveryCache; + _authenticationRepository = authenticationRepository; + + Claims = new ReadOnlyObservableCollection(_claims); + } + + 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 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; + } + + private static byte[] HashSha256(string inputString) + { + using SHA256 sha256 = SHA256.Create(); + return sha256.ComputeHash(Encoding.UTF8.GetBytes(inputString)); + } + + /// + public IObservable IsLoggedIn => _isLoggedInSubject.AsObservable(); + + /// + public ReadOnlyObservableCollection Claims { get; } + + public async Task 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(); + } + } + + /// + public async Task 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(); + } + } + + /// + 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(); + } + } + + /// + public void Logout() + { + _token = null; + _claims.Clear(); + _authenticationRepository.SetRefreshToken(null); + + _isLoggedInSubject.OnNext(false); + } +} \ 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 2e22afbba..16b05ed5e 100644 --- a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs @@ -1,222 +1,16 @@ using System.Collections.ObjectModel; -using System.IdentityModel.Tokens.Jwt; -using System.Reactive.Linq; -using System.Reactive.Subjects; 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 { IObservable IsLoggedIn { get; } - IObservable UserCode { get; } - IObservable VerificationUri { get; } ReadOnlyObservableCollection Claims { get; } Task GetBearer(); Task AutoLogin(); - Task Login(CancellationToken cancellationToken); + 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 IHttpClientFactory _httpClientFactory; - private readonly BehaviorSubject _isLoggedInSubject = new(false); - private readonly BehaviorSubject _userCodeSubject = new(null); - private readonly BehaviorSubject _verificationUriSubject = new(null); - - private AuthenticationToken? _token; - - public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) - { - _httpClientFactory = httpClientFactory; - _discoveryCache = discoveryCache; - _authenticationRepository = authenticationRepository; - - Claims = new ReadOnlyObservableCollection(_claims); - } - - 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, 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 IObservable IsLoggedIn => _isLoggedInSubject.AsObservable(); - - /// - public IObservable UserCode => _userCodeSubject.AsObservable(); - - /// - public IObservable VerificationUri => _verificationUriSubject.AsObservable(); - - /// - public ReadOnlyObservableCollection Claims { get; } - - public async Task 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(); - } - } - - /// - public async Task AutoLogin() - { - if (_isLoggedInSubject.Value) - return true; - - string? refreshToken = _authenticationRepository.GetRefreshToken(); - if (refreshToken == null) - return false; - - return await UseRefreshToken(refreshToken); - } - - /// - 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, - 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); - } - } - - /// - public void Logout() - { - _token = null; - _claims.Clear(); - _authenticationRepository.SetRefreshToken(null); - - _isLoggedInSubject.OnNext(false); - } } \ No newline at end of file