From f166de1a438eed2d05744c9ecd63136a39480395 Mon Sep 17 00:00:00 2001 From: Masu Baumgartner <68913099+Masu-Baumgartner@users.noreply.github.com> Date: Sat, 19 Oct 2024 20:09:03 +0200 Subject: [PATCH] Implemented complete oauth2 flow with modular providers The local oauth2 provider still needs A LOT of love. But the general oauth2 workflow works. The http api client needs an option to disable the authentication things and we need to switch to the local storage for storing access, refresh and timestamp for the client --- .../Http/Controllers/Auth/AuthController.cs | 4 +- .../Controllers/OAuth2/OAuth2Controller.cs | 53 ++++++++++++++-- .../OAuth2/LocalOAuth2Provider.cs | 60 +++++++++++++++++++ .../Interfaces/OAuth2/IOAuth2Provider.cs | 2 +- .../Moonlight.ApiServer.csproj | 1 - Moonlight.ApiServer/Program.cs | 36 ++--------- Moonlight.Client/Program.cs | 10 ++-- .../UI/Screens/AuthenticationScreen.razor | 28 ++++++++- .../Http/Responses/OAuth2/InfoResponse.cs | 7 +++ 9 files changed, 156 insertions(+), 45 deletions(-) create mode 100644 Moonlight.ApiServer/Implementations/OAuth2/LocalOAuth2Provider.cs create mode 100644 Moonlight.Shared/Http/Responses/OAuth2/InfoResponse.cs diff --git a/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs index ceac3095..035ade57 100644 --- a/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs +++ b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs @@ -63,7 +63,7 @@ public class AuthController : Controller throw new HttpApiException("No oauth2 provider has been registered", 500); // Sync user from oauth2 provider - var user = await provider.Sync(HttpContext.RequestServices, accessData.AccessToken, accessData.RefreshToken); + var user = await provider.Sync(HttpContext.RequestServices, accessData.AccessToken); if (user == null) throw new HttpApiException("The oauth2 provider was unable to authenticate you", 401); @@ -172,7 +172,7 @@ public class AuthController : Controller var refreshData = OAuth2Service.RefreshAccess(user.RefreshToken).Result; // Sync user with oauth2 provider - var syncedUser = provider.Sync(serviceProvider, refreshData.AccessToken, refreshData.RefreshToken).Result; + var syncedUser = provider.Sync(serviceProvider, refreshData.AccessToken).Result; if (syncedUser == null) // User sync has failed. No refresh allowed return false; diff --git a/Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs b/Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs index 061acad7..9212cc53 100644 --- a/Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs +++ b/Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs @@ -7,6 +7,7 @@ using MoonCore.Services; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Services; +using Moonlight.Shared.Http.Responses.OAuth2; namespace Moonlight.ApiServer.Http.Controllers.OAuth2; @@ -88,7 +89,7 @@ public class OAuth2Controller : Controller [FromForm(Name = "redirect_uri")] string redirectUri, [FromForm(Name = "grant_type")] string grantType, [FromForm(Name = "code")] string code - ) + ) { if (grantType != "authorization_code") throw new HttpApiException("Invalid grant type", 400); @@ -106,14 +107,56 @@ public class OAuth2Controller : Controller user = UserRepository.Get().FirstOrDefault(x => x.Id == userId); return user != null; - }, data => - { - data.Add("userId", user!.Id.ToString()); - }); + }, data => { data.Add("userId", user!.Id.ToString()); }); if (access == null) throw new HttpApiException("Unable to validate access", 400); return access; } + + [HttpGet("info")] + public async Task Info() + { + if (!Request.Headers.ContainsKey("Authorization")) + throw new HttpApiException("Authorization header is missing", 400); + + var authHeader = Request.Headers["Authorization"].First() ?? ""; + + if (string.IsNullOrEmpty(authHeader)) + throw new HttpApiException("Authorization header is missing", 400); + + User? currentUser = null; + + var isValid = await OAuth2Service.IsValidAccessToken( + authHeader, + data => + { + // Check if the userId is present in the access token + if (!data.TryGetValue("userId", out var userIdStr) || !int.TryParse(userIdStr, out var userId)) + return false; + + currentUser = UserRepository + .Get() + .FirstOrDefault(x => x.Id == userId); + + if (currentUser == null) + return false; + + return true; + } + ); + + if (!isValid) + throw new HttpApiException("Invalid access token", 401); + + if(currentUser == null) + throw new HttpApiException("Invalid access token", 401); + + return new InfoResponse() + { + Username = currentUser.Username, + Email = currentUser.Email + }; + } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Implementations/OAuth2/LocalOAuth2Provider.cs b/Moonlight.ApiServer/Implementations/OAuth2/LocalOAuth2Provider.cs new file mode 100644 index 00000000..caf64368 --- /dev/null +++ b/Moonlight.ApiServer/Implementations/OAuth2/LocalOAuth2Provider.cs @@ -0,0 +1,60 @@ +using MoonCore.Extended.Abstractions; +using MoonCore.Extensions; +using MoonCore.Services; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Database.Entities; +using Moonlight.ApiServer.Interfaces.OAuth2; +using Moonlight.Shared.Http.Responses.OAuth2; + +namespace Moonlight.ApiServer.Implementations.OAuth2; + +public class LocalOAuth2Provider : IOAuth2Provider +{ + public async Task Sync(IServiceProvider provider, string accessToken) + { + var logger = provider.GetRequiredService>(); + + try + { + var configService = provider.GetRequiredService>(); + var config = configService.Get(); + + using var httpClient = new HttpClient(); + + httpClient.DefaultRequestHeaders.Add("Authorization", accessToken); + + var response = await httpClient.GetAsync($"{config.PublicUrl}/oauth2/info"); + await response.HandlePossibleApiError(); + var info = await response.ParseAsJson(); + + var userRepo = provider.GetRequiredService>(); + var user = userRepo.Get().FirstOrDefault(x => x.Email == info.Email); + + if (user == null) // User not found, register a new one + { + user = userRepo.Add(new User() + { + Email = info.Email, + Username = info.Username + }); + } + else if (user.Username != info.Username) // Username updated? + { + // Username not used by another user? + if (!userRepo.Get().Any(x => x.Username == info.Username)) + { + // Update username + user.Username = info.Username; + userRepo.Update(user); + } + } + + return user; + } + catch (Exception e) + { + logger.LogCritical("Unable to sync user: {e}", e); + return null; + } + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Interfaces/OAuth2/IOAuth2Provider.cs b/Moonlight.ApiServer/Interfaces/OAuth2/IOAuth2Provider.cs index c0ad5acb..e63ef23e 100644 --- a/Moonlight.ApiServer/Interfaces/OAuth2/IOAuth2Provider.cs +++ b/Moonlight.ApiServer/Interfaces/OAuth2/IOAuth2Provider.cs @@ -4,5 +4,5 @@ namespace Moonlight.ApiServer.Interfaces.OAuth2; public interface IOAuth2Provider { - public Task Sync(IServiceProvider provider, string accessToken, string refreshToken); + public Task Sync(IServiceProvider provider, string accessToken); } \ No newline at end of file diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj index a3fcac0c..ca4c332c 100644 --- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj +++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj @@ -28,7 +28,6 @@ - diff --git a/Moonlight.ApiServer/Program.cs b/Moonlight.ApiServer/Program.cs index 0f88d774..5ff3c734 100644 --- a/Moonlight.ApiServer/Program.cs +++ b/Moonlight.ApiServer/Program.cs @@ -15,6 +15,8 @@ using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Helpers; using Moonlight.ApiServer.Helpers.Authentication; using Moonlight.ApiServer.Http.Middleware; +using Moonlight.ApiServer.Implementations.OAuth2; +using Moonlight.ApiServer.Interfaces.OAuth2; // Prepare file system Directory.CreateDirectory(PathBuilder.Dir("storage")); @@ -97,7 +99,7 @@ builder.Services.AddOAuth2Consumer(configuration => configuration.ClientId = config.Authentication.OAuth2.ClientId; configuration.ClientSecret = config.Authentication.OAuth2.ClientSecret; configuration.AuthorizationRedirect = - config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle"; + config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/code"; configuration.AccessEndpoint = config.Authentication.OAuth2.AccessEndpoint ?? $"{config.PublicUrl}/oauth2/access"; configuration.RefreshEndpoint = config.Authentication.OAuth2.RefreshEndpoint ?? $"{config.PublicUrl}/oauth2/refresh"; @@ -128,7 +130,7 @@ if (config.Authentication.UseLocalOAuth2) configuration.ClientSecret = config.Authentication.OAuth2.ClientSecret; configuration.CodeSecret = config.Authentication.LocalOAuth2.CodeSecret; configuration.AuthorizationRedirect = - config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle"; + config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/code"; configuration.AccessTokenDuration = 60; configuration.RefreshTokenDuration = 3600; }); @@ -148,33 +150,6 @@ builder.Services.AddTokenAuthentication(configuration => if (user == null) return false; - // OAuth2 - Check external - if (false && DateTime.UtcNow > user.RefreshTimestamp) - { - var tokenConsumer = new TokenConsumer(user.AccessToken, user.RefreshToken, user.RefreshTimestamp, - async refreshToken => - { - var oauth2Service = context.RequestServices.GetRequiredService(); - - var accessData = await oauth2Service.RefreshAccess(refreshToken); - - user.AccessToken = accessData.AccessToken; - user.RefreshToken = accessData.RefreshToken; - user.RefreshTimestamp = DateTime.UtcNow.AddSeconds(accessData.ExpiresIn); - - userRepo.Update(user); - - return new TokenPair() - { - AccessToken = user.AccessToken, - RefreshToken = user.RefreshToken - }; - }); - - //await tokenConsumer.GetAccessToken(); - //TODO: API CALL (modular) - } - // Load permissions, handle empty values var permissions = JsonSerializer.Deserialize( string.IsNullOrEmpty(user.PermissionsJson) ? "[]" : user.PermissionsJson @@ -214,7 +189,8 @@ if (configService.Get().Development.EnableApiDocs) // Implementation service var implementationService = new ImplementationService(); - +if(config.Authentication.UseLocalOAuth2) + implementationService.Register(); builder.Services.AddSingleton(implementationService); diff --git a/Moonlight.Client/Program.cs b/Moonlight.Client/Program.cs index 9ac53c51..f1a6224e 100644 --- a/Moonlight.Client/Program.cs +++ b/Moonlight.Client/Program.cs @@ -64,9 +64,9 @@ builder.Services.AddScoped(sp => var cookieService = sp.GetRequiredService(); return new TokenConsumer( - await cookieService.GetValue("ml-access"), - await cookieService.GetValue("ml-refresh"), - DateTimeOffset.FromUnixTimeSeconds(long.Parse(await cookieService.GetValue("ml-timestamp"))).UtcDateTime, + await cookieService.GetValue("kms-access", "x"), + await cookieService.GetValue("kms-refresh", "x"), + DateTimeOffset.FromUnixTimeSeconds(long.Parse(await cookieService.GetValue("kms-timestamp", "0"))).UtcDateTime, async refreshToken => { await httpClient.PostAsync("api/auth/refresh", new StringContent( @@ -78,8 +78,8 @@ builder.Services.AddScoped(sp => return new TokenPair() { - AccessToken = await cookieService.GetValue("ml-access"), - RefreshToken = await cookieService.GetValue("ml-refresh") + AccessToken = await cookieService.GetValue("kms-access", "x"), + RefreshToken = await cookieService.GetValue("kms-refresh", "x") }; } ); diff --git a/Moonlight.Client/UI/Screens/AuthenticationScreen.razor b/Moonlight.Client/UI/Screens/AuthenticationScreen.razor index 4f86a28f..f2f61668 100644 --- a/Moonlight.Client/UI/Screens/AuthenticationScreen.razor +++ b/Moonlight.Client/UI/Screens/AuthenticationScreen.razor @@ -1,8 +1,12 @@ +@page "/auth" + @using MoonCore.Helpers +@using Moonlight.Shared.Http.Requests.Auth @using Moonlight.Shared.Http.Responses.Auth @inject NavigationManager Navigation @inject HttpApiClient HttpApiClient +@inject CookieService CookieService
Authenticate @@ -10,9 +14,31 @@ @code { + [SupplyParameterFromQuery(Name = "code")] + [Parameter] + public string? Code { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if(!firstRender) + return; + + if(Code == null) + return; + + var authHandleData = await HttpApiClient.PostJson("api/auth", new OAuth2HandleRequest() + { + Code = Code + }); + + await CookieService.SetValue("kms-access", authHandleData.AccessToken, 10); + await CookieService.SetValue("kms-refresh", authHandleData.RefreshToken, 10); + await CookieService.SetValue("kms-timestamp", DateTimeOffset.UtcNow.AddSeconds(60).ToUnixTimeSeconds().ToString(), 10); + } + private async Task StartAuth(WButton _) { - var authStartData = await HttpApiClient.GetJson("api/auth/start"); + var authStartData = await HttpApiClient.GetJson("api/auth"); var uri = authStartData.Endpoint + $"?client_id={authStartData.ClientId}" + diff --git a/Moonlight.Shared/Http/Responses/OAuth2/InfoResponse.cs b/Moonlight.Shared/Http/Responses/OAuth2/InfoResponse.cs new file mode 100644 index 00000000..11b1b08a --- /dev/null +++ b/Moonlight.Shared/Http/Responses/OAuth2/InfoResponse.cs @@ -0,0 +1,7 @@ +namespace Moonlight.Shared.Http.Responses.OAuth2; + +public class InfoResponse +{ + public string Username { get; set; } + public string Email { get; set; } +} \ No newline at end of file