Cleaned up pagination in user and apikey controller. Extracted login start and start url generation to modular IOAuth2Provider interface. Improved login and register local oauth2 page

This commit is contained in:
2025-07-24 09:23:36 +02:00
parent 6a29b5386c
commit 123b64a666
12 changed files with 145 additions and 170 deletions

View File

@@ -28,8 +28,8 @@ public class ApiKeysController : Controller
[HttpGet] [HttpGet]
[Authorize(Policy = "permissions:admin.apikeys.get")] [Authorize(Policy = "permissions:admin.apikeys.get")]
public async Task<IPagedData<ApiKeyResponse>> Get( public async Task<IPagedData<ApiKeyResponse>> Get(
[FromQuery] int page, [FromQuery] [Range(0, int.MaxValue)] int page,
[FromQuery] [Range(1, 100)] int pageSize = 50 [FromQuery] [Range(1, 100)] int pageSize
) )
{ {
var count = await ApiKeyRepository.Get().CountAsync(); var count = await ApiKeyRepository.Get().CountAsync();

View File

@@ -27,7 +27,7 @@ public class UsersController : Controller
[Authorize(Policy = "permissions:admin.users.get")] [Authorize(Policy = "permissions:admin.users.get")]
public async Task<IPagedData<UserResponse>> Get( public async Task<IPagedData<UserResponse>> Get(
[FromQuery] [Range(0, int.MaxValue)] int page, [FromQuery] [Range(0, int.MaxValue)] int page,
[FromQuery] [Range(1, 100)] int pageSize = 50 [FromQuery] [Range(1, 100)] int pageSize
) )
{ {
var count = await UserRepository.Get().CountAsync(); var count = await UserRepository.Get().CountAsync();

View File

@@ -1,9 +1,9 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using MoonCore.Exceptions; using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions; using MoonCore.Extended.Abstractions;
@@ -20,16 +20,11 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth;
public class AuthController : Controller public class AuthController : Controller
{ {
private readonly AppConfiguration Configuration; private readonly AppConfiguration Configuration;
private readonly ILogger<AuthController> Logger;
private readonly DatabaseRepository<User> UserRepository; private readonly DatabaseRepository<User> UserRepository;
private readonly IOAuth2Provider OAuth2Provider; private readonly IOAuth2Provider OAuth2Provider;
private readonly string RedirectUri;
private readonly string EndpointUri;
public AuthController( public AuthController(
AppConfiguration configuration, AppConfiguration configuration,
ILogger<AuthController> logger,
DatabaseRepository<User> userRepository, DatabaseRepository<User> userRepository,
IOAuth2Provider oAuth2Provider IOAuth2Provider oAuth2Provider
) )
@@ -37,36 +32,25 @@ public class AuthController : Controller
UserRepository = userRepository; UserRepository = userRepository;
OAuth2Provider = oAuth2Provider; OAuth2Provider = oAuth2Provider;
Configuration = configuration; Configuration = configuration;
Logger = logger;
RedirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
? Configuration.PublicUrl
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
EndpointUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationEndpoint)
? Configuration.PublicUrl + "/oauth2/authorize"
: Configuration.Authentication.OAuth2.AuthorizationEndpoint;
} }
[AllowAnonymous] [AllowAnonymous]
[HttpGet("start")] [HttpGet("start")]
public Task<LoginStartResponse> Start() public async Task<LoginStartResponse> Start()
{ {
var response = new LoginStartResponse() var url = await OAuth2Provider.Start();
{
ClientId = Configuration.Authentication.OAuth2.ClientId,
RedirectUri = RedirectUri,
Endpoint = EndpointUri
};
return Task.FromResult(response); return new LoginStartResponse()
{
Url = url
};
} }
[AllowAnonymous] [AllowAnonymous]
[HttpPost("complete")] [HttpPost("complete")]
public async Task<LoginCompleteResponse> Complete([FromBody] LoginCompleteRequest request) public async Task<LoginCompleteResponse> Complete([FromBody] LoginCompleteRequest request)
{ {
var user = await OAuth2Provider.Sync(request.Code); var user = await OAuth2Provider.Complete(request.Code);
if (user == null) if (user == null)
throw new HttpApiException("Unable to load user data", 500); throw new HttpApiException("Unable to load user data", 500);
@@ -113,8 +97,8 @@ public class AuthController : Controller
[HttpGet("check")] [HttpGet("check")]
public async Task<CheckResponse> Check() public async Task<CheckResponse> Check()
{ {
var userIdClaim = User.Claims.First(x => x.Type == "userId"); var userIdStr = User.FindFirstValue("userId")!;
var userId = int.Parse(userIdClaim.Value); var userId = int.Parse(userIdStr);
var user = await UserRepository.Get().FirstAsync(x => x.Id == userId); var user = await UserRepository.Get().FirstAsync(x => x.Id == userId);
return new() return new()

View File

@@ -7,10 +7,10 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@Configuration.Title</title> <title>@Title</title>
<base href="/"/> <base href="/"/>
@foreach (var style in Configuration.Styles) @foreach (var style in Styles)
{ {
<link rel="stylesheet" href="@style"/> <link rel="stylesheet" href="@style"/>
} }
@@ -86,7 +86,7 @@
</div> </div>
@foreach (var script in Configuration.Scripts) @foreach (var script in Scripts)
{ {
<script src="@script"></script> <script src="@script"></script>
} }
@@ -99,6 +99,8 @@
@code @code
{ {
[Parameter] public FrontendConfiguration Configuration { get; set; } [Parameter] public string Title { get; set; }
[Parameter] public string[] Scripts { get; set; }
[Parameter] public string[] Styles { get; set; }
[Parameter] public Theme? Theme { get; set; } [Parameter] public Theme? Theme { get; set; }
} }

View File

@@ -3,56 +3,51 @@
<title>Login into your account</title> <title>Login into your account</title>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script> <link rel="stylesheet" type="text/css" href="/css/style.min.css"/>
</head> </head>
<body class="h-full"> <body class="h-full">
<div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8"> <div class="flex h-auto min-h-screen items-center justify-center overflow-x-hidden bg-background py-10">
<div class="sm:mx-auto sm:w-full sm:max-w-md"> <div class="relative flex items-center justify-center px-4 sm:px-6 lg:px-8">
<img class="mx-auto h-14 w-auto" src="/svg/logo.svg" alt="Moonlight"> <div
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-100">Login into your account</h2> class="bg-base-100 shadow-base-300/20 z-1 w-full space-y-6 rounded-xl p-6 shadow-md sm:min-w-md lg:p-8">
<div class="flex justify-center items-center gap-3">
<img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/>
</div> </div>
<div class="text-center">
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]"> <h3 class="text-base-content mb-1.5 text-2xl font-semibold">Login into your account</h3>
<div class="bg-gray-800 px-6 py-12 shadow sm:rounded-lg sm:px-12"> <p class="text-base-content/80">After logging in you will be able to manage your services</p>
</div>
<div class="space-y-4">
@if (!string.IsNullOrEmpty(ErrorMessage)) @if (!string.IsNullOrEmpty(ErrorMessage))
{ {
<div class="rounded-lg bg-red-500 p-5 text-center text-white mb-8"> <div class="alert alert-error text-center">
@ErrorMessage @ErrorMessage
</div> </div>
} }
<form class="space-y-6" method="POST"> <form class="mb-4 space-y-4" method="post">
<div> <div>
<label for="email" class="block text-sm font-medium leading-6 text-gray-100">Email address</label> <label class="label-text" for="email">Email address</label>
<div class="mt-2"> <input type="email" name="email" placeholder="Enter your email address" class="input" id="email"
<input id="email" name="email" type="email" autocomplete="email" required class="block bg-white/5 w-full rounded-md border-0 py-1.5 text-gray-100 shadow-sm ring-1 ring-inset ring-gray-700 placeholder:text-gray-600 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"> required/>
</div> </div>
</div>
<div> <div>
<label for="password" class="block text-sm font-medium leading-6 text-gray-100">Password</label> <label class="label-text" for="password">Password</label>
<div class="mt-2"> <input class="input" name="password" id="password" type="password" placeholder="············"
<input id="password" name="password" type="password" autocomplete="current-password" required class="block bg-white/5 w-full rounded-md border-0 py-1.5 text-gray-100 shadow-sm ring-1 ring-inset ring-gray-700 placeholder:text-gray-600 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"> required/>
</div>
</div>
<div>
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Login
</button>
</div> </div>
<button class="btn btn-lg btn-primary btn-gradient btn-block">Login</button>
</form> </form>
</div> <p class="text-base-content/80 mb-4 text-center">
<p class="mt-5 text-center text-sm text-gray-500">
No account? No account?
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=register" class="font-semibold leading-6 text-indigo-600 hover:text-indigo-500"> <a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=register"
Register class="link link-animated link-primary font-normal">Create an account</a>
</a>
</p> </p>
</div> </div>
</div>
</div>
</div> </div>
</body> </body>

View File

@@ -3,63 +3,57 @@
<title>Register a new account</title> <title>Register a new account</title>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script> <link rel="stylesheet" type="text/css" href="/css/style.min.css"/>
</head> </head>
<body class="h-full"> <body class="h-full">
<div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8"> <div class="flex h-auto min-h-screen items-center justify-center overflow-x-hidden bg-background py-10">
<div class="sm:mx-auto sm:w-full sm:max-w-md"> <div class="relative flex items-center justify-center px-4 sm:px-6 lg:px-8">
<img class="mx-auto h-14 w-auto" src="/svg/logo.svg" alt="Moonlight"> <div
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-100">Create your account</h2> class="bg-base-100 shadow-base-300/20 z-1 w-full space-y-6 rounded-xl p-6 shadow-md sm:min-w-md lg:p-8">
<div class="flex justify-center items-center gap-3">
<img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/>
</div> </div>
<div class="text-center">
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]"> <h3 class="text-base-content mb-1.5 text-2xl font-semibold">Register a new account</h3>
<div class="bg-gray-800 px-6 py-12 shadow sm:rounded-lg sm:px-12"> <p class="text-base-content/80">After signing up you will be able to manage your services</p>
</div>
<div class="space-y-4">
@if (!string.IsNullOrEmpty(ErrorMessage)) @if (!string.IsNullOrEmpty(ErrorMessage))
{ {
<div class="rounded-lg bg-red-500 p-5 text-center text-white mb-8"> <div class="alert alert-error text-center">
@ErrorMessage @ErrorMessage
</div> </div>
} }
<form class="space-y-6" method="POST"> <form class="mb-4 space-y-4" method="post">
<div> <div>
<label for="username" class="block text-sm font-medium leading-6 text-gray-100">Username</label> <label class="label-text" for="username">Username</label>
<div class="mt-2"> <input type="text" name="username" placeholder="Enter your username" class="input" id="username"
<input id="username" name="username" type="text" required class="block bg-white/5 w-full rounded-md border-0 py-1.5 text-gray-100 shadow-sm ring-1 ring-inset ring-gray-700 placeholder:text-gray-600 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"> required/>
</div> </div>
</div>
<div> <div>
<label for="email" class="block text-sm font-medium leading-6 text-gray-100">Email address</label> <label class="label-text" for="email">Email address</label>
<div class="mt-2"> <input type="email" name="email" placeholder="Enter your email address" class="input" id="email"
<input id="email" name="email" type="email" autocomplete="email" required class="block bg-white/5 w-full rounded-md border-0 py-1.5 text-gray-100 shadow-sm ring-1 ring-inset ring-gray-700 placeholder:text-gray-600 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"> required/>
</div> </div>
</div>
<div> <div>
<label for="password" class="block text-sm font-medium leading-6 text-gray-100">Password</label> <label class="label-text" for="password">Password</label>
<div class="mt-2"> <input class="input" name="password" id="password" type="password" placeholder="············"
<input id="password" name="password" type="password" autocomplete="current-password" required class="block bg-white/5 w-full rounded-md border-0 py-1.5 text-gray-100 shadow-sm ring-1 ring-inset ring-gray-700 placeholder:text-gray-600 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"> required/>
</div>
</div>
<div>
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Create your account
</button>
</div> </div>
<button class="btn btn-lg btn-primary btn-gradient btn-block">Register</button>
</form> </form>
</div> <p class="text-base-content/80 mb-4 text-center">
<p class="mt-5 text-center text-sm text-gray-500">
Already registered? Already registered?
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=login" class="font-semibold leading-6 text-indigo-600 hover:text-indigo-500">Login</a> <a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=login"
class="link link-animated link-primary font-normal">Login into your account</a>
</p> </p>
</div> </div>
</div>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -27,7 +27,27 @@ public class LocalOAuth2Provider : IOAuth2Provider
Logger = logger; Logger = logger;
} }
public async Task<User?> Sync(string code) public Task<string> Start()
{
var redirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
? Configuration.PublicUrl
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
var endpoint = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationEndpoint)
? Configuration.PublicUrl + "/oauth2/authorize"
: Configuration.Authentication.OAuth2.AuthorizationEndpoint;
var clientId = Configuration.Authentication.OAuth2.ClientId;
var url = $"{endpoint}" +
$"?client_id={clientId}" +
$"&redirect_uri={redirectUri}" +
$"&response_type=code";
return Task.FromResult(url);
}
public async Task<User?> Complete(string code)
{ {
// Create http client to call the auth provider // Create http client to call the auth provider
var httpClient = new HttpClient(); var httpClient = new HttpClient();
@@ -70,6 +90,10 @@ public class LocalOAuth2Provider : IOAuth2Provider
throw new HttpApiException("Unable to request user data", 500); throw new HttpApiException("Unable to request user data", 500);
} }
// Notice: We just look up the user id here
// which works as our oauth2 provider is using the same db.
// a real oauth2 provider would create a user here
// Handle the returned data // Handle the returned data
var userId = handleData.UserId; var userId = handleData.UserId;

View File

@@ -4,5 +4,7 @@ namespace Moonlight.ApiServer.Interfaces;
public interface IOAuth2Provider public interface IOAuth2Provider
{ {
public Task<User?> Sync(string code); public Task<string> Start();
public Task<User?> Complete(string code);
} }

View File

@@ -40,53 +40,44 @@ public class FrontendService
ThemeRepository = themeRepository; ThemeRepository = themeRepository;
} }
public async Task<FrontendConfiguration> GetConfiguration() public Task<FrontendConfiguration> GetConfiguration()
{ {
var configuration = new FrontendConfiguration() var configuration = new FrontendConfiguration()
{ {
Title = "Moonlight", // TODO: CONFIG
ApiUrl = Configuration.PublicUrl, ApiUrl = Configuration.PublicUrl,
HostEnvironment = "ApiServer" HostEnvironment = "ApiServer"
}; };
// Load theme.json if it exists return Task.FromResult(configuration);
var themePath = Path.Combine("storage", "theme.json");
if (File.Exists(themePath))
{
var variablesJson = await File.ReadAllTextAsync(themePath);
configuration.Theme.Variables = JsonSerializer
.Deserialize<Dictionary<string, string>>(variablesJson) ?? new();
}
// Collect scripts to execute
configuration.Scripts = ConfigurationOptions
.SelectMany(x => x.Scripts)
.ToArray();
// Collect styles
configuration.Styles = ConfigurationOptions
.SelectMany(x => x.Styles)
.ToArray();
return configuration;
} }
public async Task<string> GenerateIndexHtml() // TODO: Cache public async Task<string> GenerateIndexHtml() // TODO: Cache
{ {
var configuration = await GetConfiguration(); // Load requested theme
var theme = await ThemeRepository var theme = await ThemeRepository
.Get() .Get()
.FirstOrDefaultAsync(x => x.IsEnabled); .FirstOrDefaultAsync(x => x.IsEnabled);
// Load configured javascript files
var scripts = ConfigurationOptions
.SelectMany(x => x.Scripts)
.Distinct()
.ToArray();
// Load configured css files
var styles = ConfigurationOptions
.SelectMany(x => x.Styles)
.Distinct()
.ToArray();
return await ComponentHelper.RenderComponent<FrontendPage>( return await ComponentHelper.RenderComponent<FrontendPage>(
ServiceProvider, ServiceProvider,
parameters => parameters =>
{ {
parameters["Configuration"] = configuration;
parameters["Theme"] = theme!; parameters["Theme"] = theme!;
parameters["Styles"] = styles;
parameters["Scripts"] = scripts;
parameters["Title"] = "Moonlight"; // TODO: Config
} }
); );
} }

View File

@@ -83,12 +83,7 @@ public class RemoteAuthStateManager : AuthenticationStateManager
{ {
var loginStartData = await HttpApiClient.GetJson<LoginStartResponse>("api/auth/start"); var loginStartData = await HttpApiClient.GetJson<LoginStartResponse>("api/auth/start");
var url = $"{loginStartData.Endpoint}" + NavigationManager.NavigateTo(loginStartData.Url, true);
$"?client_id={loginStartData.ClientId}" +
$"&redirect_uri={loginStartData.RedirectUri}" +
$"&response_type=code";
NavigationManager.NavigateTo(url, true);
} }
private async Task<AuthenticationState> LoadAuthState() private async Task<AuthenticationState> LoadAuthState()

View File

@@ -2,7 +2,5 @@ namespace Moonlight.Shared.Http.Responses.Auth;
public class LoginStartResponse public class LoginStartResponse
{ {
public string ClientId { get; set; } public string Url { get; set; }
public string Endpoint { get; set; }
public string RedirectUri { get; set; }
} }

View File

@@ -2,16 +2,6 @@ namespace Moonlight.Shared.Misc;
public class FrontendConfiguration public class FrontendConfiguration
{ {
public string Title { get; set; }
public string ApiUrl { get; set; } public string ApiUrl { get; set; }
public string HostEnvironment { get; set; } public string HostEnvironment { get; set; }
public ThemeData Theme { get; set; } = new();
public string[] Scripts { get; set; }
public string[] Styles { get; set; }
public string[] Assemblies { get; set; }
public class ThemeData
{
public Dictionary<string, string> Variables { get; set; } = new();
}
} }