diff --git a/Moonlight.ApiServer/Configuration/AppConfiguration.cs b/Moonlight.ApiServer/Configuration/AppConfiguration.cs index 07232307..2dbfc4cf 100644 --- a/Moonlight.ApiServer/Configuration/AppConfiguration.cs +++ b/Moonlight.ApiServer/Configuration/AppConfiguration.cs @@ -1,4 +1,5 @@ using MoonCore.Helpers; +using Moonlight.ApiServer.Implementations.LocalAuth; using YamlDotNet.Serialization; namespace Moonlight.ApiServer.Configuration; @@ -29,6 +30,10 @@ public record AppConfiguration Kestrel = new() { AllowedOrigins = [] + }, + Authentication = new() + { + EnabledSchemes = [] } }; } @@ -60,6 +65,9 @@ public record AppConfiguration [YamlMember(Description = "This specifies if the first registered/synced user will become an admin automatically")] public bool FirstUserAdmin { get; set; } = true; + + [YamlMember(Description = "This specifies the authentication schemes the frontend should be able to challenge")] + public string[] EnabledSchemes { get; set; } = [LocalAuthConstants.AuthenticationScheme]; } public record SessionsConfig diff --git a/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs index 38f9fb6a..2af686d2 100644 --- a/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs +++ b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs @@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Implementations.LocalAuth; +using Moonlight.ApiServer.Interfaces; using Moonlight.Shared.Http.Responses.Auth; namespace Moonlight.ApiServer.Http.Controllers.Auth; @@ -13,13 +15,18 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth; public class AuthController : Controller { private readonly IAuthenticationSchemeProvider SchemeProvider; + private readonly IEnumerable Extensions; + private readonly AppConfiguration Configuration; - // Add schemes which should be offered to the client here - private readonly string[] SchemeWhitelist = [LocalAuthConstants.AuthenticationScheme]; - - public AuthController(IAuthenticationSchemeProvider schemeProvider) + public AuthController( + IAuthenticationSchemeProvider schemeProvider, + IEnumerable extensions, + AppConfiguration configuration + ) { SchemeProvider = schemeProvider; + Extensions = extensions; + Configuration = configuration; } [HttpGet] @@ -27,8 +34,10 @@ public class AuthController : Controller { var schemes = await SchemeProvider.GetAllSchemesAsync(); + var allowedSchemes = Configuration.Authentication.EnabledSchemes; + return schemes - .Where(x => SchemeWhitelist.Contains(x.Name)) + .Where(x => allowedSchemes.Contains(x.Name)) .Select(scheme => new AuthSchemeResponse() { DisplayName = scheme.DisplayName ?? scheme.Name, @@ -40,11 +49,10 @@ public class AuthController : Controller [HttpGet("{identifier:alpha}")] public async Task StartScheme([FromRoute] string identifier) { - var scheme = await SchemeProvider.GetSchemeAsync(identifier); + // Validate identifier against our enable list + var allowedSchemes = Configuration.Authentication.EnabledSchemes; - // The check for the whitelist ensures a user isn't starting an auth flow - // which isn't meant for users - if (scheme == null || !SchemeWhitelist.Contains(scheme.Name)) + if (!allowedSchemes.Contains(identifier)) { await Results .Problem( @@ -56,6 +64,22 @@ public class AuthController : Controller return; } + // Now we can check if it even exists + var scheme = await SchemeProvider.GetSchemeAsync(identifier); + + if (scheme == null) + { + await Results + .Problem( + "Invalid scheme identifier provided", + statusCode: 404 + ) + .ExecuteAsync(HttpContext); + + return; + } + + // Everything fine, challenge the frontend await HttpContext.ChallengeAsync( scheme.Name, new AuthenticationProperties() @@ -67,7 +91,7 @@ public class AuthController : Controller [Authorize] [HttpGet("check")] - public Task Check() + public async Task Check() { var username = User.FindFirstValue(ClaimTypes.Name)!; var id = User.FindFirstValue(ClaimTypes.NameIdentifier)!; @@ -75,6 +99,7 @@ public class AuthController : Controller var userId = User.FindFirstValue("UserId")!; var permissions = User.FindFirstValue("Permissions")!; + // Create basic set of claims used by the frontend var claims = new List() { new(ClaimTypes.Name, username), @@ -84,9 +109,15 @@ public class AuthController : Controller new("Permissions", permissions) }; - return Task.FromResult( - claims.ToArray() - ); + // Enrich the frontend claims by extensions (used by plugins) + foreach (var extension in Extensions) + { + claims.AddRange( + await extension.GetFrontendClaims(User) + ); + } + + return claims.ToArray(); } [HttpGet("logout")] diff --git a/Moonlight.ApiServer/Interfaces/IAuthCheckExtension.cs b/Moonlight.ApiServer/Interfaces/IAuthCheckExtension.cs new file mode 100644 index 00000000..9183682b --- /dev/null +++ b/Moonlight.ApiServer/Interfaces/IAuthCheckExtension.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; +using Moonlight.Shared.Http.Responses.Auth; + +namespace Moonlight.ApiServer.Interfaces; + +public interface IAuthCheckExtension +{ + /// + /// This function will be called by the frontend reaching out to the api server for claim information. + /// You can use this function to give your frontend plugins access to user specific data which is + /// static for the session. E.g. the avatar url of a user + /// + /// The principal of the current signed-in user + /// An array of claim responses which gets added to the list of claims to send to the frontend + public Task GetFrontendClaims(ClaimsPrincipal principal); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Interfaces/IUserAuthExtension.cs b/Moonlight.ApiServer/Interfaces/IUserAuthExtension.cs new file mode 100644 index 00000000..965efbb0 --- /dev/null +++ b/Moonlight.ApiServer/Interfaces/IUserAuthExtension.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; +using Moonlight.ApiServer.Database.Entities; + +namespace Moonlight.ApiServer.Interfaces; + +public interface IUserAuthExtension +{ + /// + /// This function is called on every sign-in. It should be used to synchronize additional user data from the principal + /// or extend the claims saved in the user session + /// + /// The current user this method is called for + /// The principal after being processed by moonlight itself + /// The result of the synchronisation. Returning false will immediately invalidate the sign-in and no other extensions will be called + public Task Sync(User user, ClaimsPrincipal principal); + + /// + /// IMPORTANT: Please note that heavy operations should not occur in this method as it will be called for every request + /// of every user + /// + /// The current user this method is called for + /// The principal after being processed by moonlight itself + /// The result of the validation. Returning false will immediately invalidate the users session and no other extensions will be called + public Task Validate(User user, ClaimsPrincipal principal); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Services/UserAuthService.cs b/Moonlight.ApiServer/Services/UserAuthService.cs index 8b54c1d1..18893e08 100644 --- a/Moonlight.ApiServer/Services/UserAuthService.cs +++ b/Moonlight.ApiServer/Services/UserAuthService.cs @@ -6,6 +6,7 @@ using MoonCore.Extended.Helpers; using MoonCore.Helpers; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Database.Entities; +using Moonlight.ApiServer.Interfaces; namespace Moonlight.ApiServer.Services; @@ -14,6 +15,7 @@ public class UserAuthService private readonly ILogger Logger; private readonly DatabaseRepository UserRepository; private readonly AppConfiguration Configuration; + private readonly IEnumerable Extensions; private const string UserIdClaim = "UserId"; private const string IssuedAtClaim = "IssuedAt"; @@ -21,12 +23,14 @@ public class UserAuthService public UserAuthService( ILogger logger, DatabaseRepository userRepository, - AppConfiguration configuration + AppConfiguration configuration, + IEnumerable extensions ) { Logger = logger; UserRepository = userRepository; Configuration = configuration; + Extensions = extensions; } public async Task Sync(ClaimsPrincipal? principal) @@ -93,11 +97,21 @@ public class UserAuthService await UserRepository.Update(user); } + // Enrich claims with required metadata principal.Identities.First().AddClaims([ new Claim(UserIdClaim, user.Id.ToString()), new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()), new Claim("Permissions", string.Join(';', user.Permissions)) ]); + + // Call extensions + foreach (var extension in Extensions) + { + var result = await extension.Sync(user, principal); + + if (!result) // Exit immediately if result is false + return false; + } return true; } @@ -137,6 +151,18 @@ public class UserAuthService // everything is fine. If not it means that the token should be invalidated // as it is too old - return issuedAt > user.TokenValidTimestamp; + if (issuedAt < user.TokenValidTimestamp) + return false; + + // Call extensions + foreach (var extension in Extensions) + { + var result = await extension.Validate(user, principal); + + if (!result) // Exit immediately if result is false + return false; + } + + return true; } } \ No newline at end of file diff --git a/Moonlight.Client/UI/Partials/LoginSelector.razor b/Moonlight.Client/UI/Partials/LoginSelector.razor index 71b36a1e..c1b9d54e 100644 --- a/Moonlight.Client/UI/Partials/LoginSelector.razor +++ b/Moonlight.Client/UI/Partials/LoginSelector.razor @@ -10,7 +10,7 @@ @if (ShowSelection) { -
Login to MoonCore
+
Login to Moonlight

Choose a login provider to start using the app