mirror of
https://github.com/Artemis-RGB/Artemis
synced 2026-11-11 11:26:27 +00:00
Added basic login
This commit is contained in:
1 parent
bde4e861c2
commit
4224875fb0
14 files changed
+524
-5
No files matched your search
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Artemis.WebClient.Workshop.Entities;
|
||||
|
||||
public class RefreshTokenEntity
|
||||
{
|
||||
public string RefreshToken { get; set; }
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user