Added authenication/authorization. Refactored api startup.
Some checks failed
ci / build-test (push) Has been cancelled
ci / publish-image (push) Has been cancelled

This commit is contained in:
2026-02-16 00:20:02 -05:00
parent a85989a337
commit 9f30ef446a
25 changed files with 685 additions and 154 deletions

View File

@@ -1,4 +1,8 @@
<HeadContent>
@using JSMR.UI.Blazor.Services
@inject SessionState Session
<HeadContent>
<RadzenTheme Theme="material-dark" />
</HeadContent>
@@ -13,4 +17,11 @@
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</Router>
@code {
protected override async Task OnInitializedAsync()
{
await Session.RefreshAsync();
}
}

View File

@@ -0,0 +1,35 @@
@using JSMR.UI.Blazor.Services
@inject SessionState Session
@inject NavigationManager Nav
@if (!ready)
{
<p>Loading...</p>
}
else if (!Session.IsAuthenticated)
{
<!-- nothing shown, we redirect -->
}
else
{
@ChildContent
}
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
private bool ready;
protected override async Task OnInitializedAsync()
{
await Session.RefreshAsync();
ready = true;
if (!Session.IsAuthenticated)
{
var returnUrl = Uri.EscapeDataString(Nav.Uri);
Nav.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: false);
}
}
}

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.13" />
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="Radzen.Blazor" Version="8.3.5" />
</ItemGroup>

View File

@@ -1,4 +1,20 @@
@inherits LayoutComponentBase
@using JSMR.UI.Blazor.Services
@inject SessionState Session
@inherits LayoutComponentBase
<div class="topbar">
@if (Session.IsAuthenticated)
{
<span>Logged in as <b>@Session.Me?.name</b> (@Session.Me?.role)</span>
<a href="" @onclick="OnLogout" style="margin-left: 12px;">Logout</a>
}
else
{
<a href="/login">Login</a>
}
</div>
<MudLayout>
<MudAppBar Elevation="1" Dense="@_dense">
@@ -56,4 +72,21 @@
StateHasChanged();
}
}
protected override void OnInitialized()
{
Session.Changed += OnSessionChanged;
}
private void OnSessionChanged() => InvokeAsync(StateHasChanged);
private async Task OnLogout(MouseEventArgs _)
{
await Session.LogoutAsync();
}
public void Dispose()
{
Session.Changed -= OnSessionChanged;
}
}

View File

@@ -0,0 +1,86 @@
@page "/login"
@using JSMR.UI.Blazor.Services
@inject SessionState Session
@inject NavigationManager Nav
<h3>Login</h3>
@if (Session.IsAuthenticated)
{
<p>You're already logged in as <b>@Session.Me?.name</b>.</p>
<button @onclick="Logout">Logout</button>
}
else
{
<div style="max-width: 360px;">
<div>
<label>Username</label><br />
<input @bind="username" />
</div>
<div style="margin-top: 8px;">
<label>Password</label><br />
<input type="password" @bind="password" />
</div>
<div style="margin-top: 12px;">
<button @onclick="LoginAsync" disabled="@busy">Login</button>
</div>
@if (!string.IsNullOrWhiteSpace(error))
{
<p style="color: crimson; margin-top: 8px;">@error</p>
}
</div>
}
@code {
private string username = "";
private string password = "";
private bool busy;
private string? error;
private async Task LoginAsync()
{
busy = true;
error = null;
try
{
var ok = await Session.LoginAsync(username, password);
if (!ok)
{
error = "Invalid username or password.";
return;
}
Nav.NavigateTo("/");
}
catch (Exception ex)
{
error = ex.Message;
}
finally
{
busy = false;
}
}
private async Task Logout()
{
busy = true;
error = null;
try
{
await Session.LogoutAsync();
}
catch (Exception ex)
{
error = ex.Message;
}
finally
{
busy = false;
}
}
}

View File

@@ -2,6 +2,7 @@
@using JSMR.Application.Common.Search
@using JSMR.Application.VoiceWorks.Queries.Search
@using JSMR.UI.Blazor.Components
@using JSMR.UI.Blazor.Components.Authentication
@using JSMR.UI.Blazor.Enums
@using JSMR.UI.Blazor.Filters
@using JSMR.UI.Blazor.Services
@@ -11,6 +12,7 @@
@inherits SearchPageBase<VoiceWorkFilterState, VoiceWorkSearchResult>
<RequireAuthentication>
<PageTitle>Voice Works</PageTitle>
<h3>Voice Works</h3>
@@ -40,6 +42,7 @@
</RightContent>
</JPagination>
}
</RequireAuthentication>
@code {
[Inject]

View File

@@ -14,7 +14,31 @@ string apiBase = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.
Console.WriteLine(apiBase);
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBase) });
// Old way
//builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBase) });
// Register the handler
builder.Services.AddTransient<IncludeRequestCredentialsMessageHandler>();
//builder.Services.AddSingleton<TokenStore>();
//builder.Services.AddTransient<JwtAuthorizationMessageHandler>();
// Register a named client that uses the handler
builder.Services.AddHttpClient("Api", client =>
{
client.BaseAddress = new Uri(apiBase);
})
.AddHttpMessageHandler<IncludeRequestCredentialsMessageHandler>();
//.AddHttpMessageHandler<JwtAuthorizationMessageHandler>();
// Keep your existing pattern (inject HttpClient) by mapping it to the named client
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient("Api")
);
builder.Services.AddScoped<AuthenticationClient>();
builder.Services.AddScoped<SessionState>();
builder.Services.AddMudServices();
builder.Services.AddRadzenComponents();
builder.Services.AddBitBlazorUIServices();

View File

@@ -0,0 +1,29 @@
using System.Net;
using System.Net.Http.Json;
namespace JSMR.UI.Blazor.Services;
public class AuthenticationClient(HttpClient http)
{
public async Task<bool> LoginAsync(string username, string password, CancellationToken ct = default)
{
var resp = await http.PostAsJsonAsync("/auth/login", new { username, password }, ct);
return resp.IsSuccessStatusCode;
}
public async Task LogoutAsync(CancellationToken ct = default)
=> await http.PostAsync("/auth/logout", content: null, ct);
public async Task<MeResponse?> GetMeAsync(CancellationToken ct = default)
{
var resp = await http.GetAsync("/api/me", ct);
if (resp.StatusCode == HttpStatusCode.Unauthorized)
return null;
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<MeResponse>(cancellationToken: ct);
}
public sealed record MeResponse(string? name, string? id, string? role);
}

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace JSMR.UI.Blazor.Services;
public sealed class IncludeRequestCredentialsMessageHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Tells browser fetch() to send cookies/auth headers on cross-origin requests
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,21 @@
using System.Net.Http.Headers;
namespace JSMR.UI.Blazor.Services;
public sealed class JwtAuthorizationMessageHandler(TokenStore tokens) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(tokens.AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}
return base.SendAsync(request, ct);
}
}
public sealed class TokenStore
{
public string? AccessToken { get; set; }
}

View File

@@ -0,0 +1,28 @@
namespace JSMR.UI.Blazor.Services;
public sealed class SessionState(AuthenticationClient auth)
{
public AuthenticationClient.MeResponse? Me { get; private set; }
public bool IsAuthenticated => Me is not null;
public event Action? Changed;
public async Task RefreshAsync(CancellationToken ct = default)
{
Me = await auth.GetMeAsync(ct);
Changed?.Invoke();
}
public async Task<bool> LoginAsync(string username, string password, CancellationToken ct = default)
{
var ok = await auth.LoginAsync(username, password, ct);
await RefreshAsync(ct);
return ok;
}
public async Task LogoutAsync(CancellationToken ct = default)
{
await auth.LogoutAsync(ct);
await RefreshAsync(ct);
}
}