Compare commits
3 Commits
652143d738
...
459079d708
| Author | SHA1 | Date | |
|---|---|---|---|
| 459079d708 | |||
|
|
220581579b | ||
|
|
254e98b37b |
@@ -34,7 +34,7 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
|
||||
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
|
||||
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (login) and one named `"MilkstreamClient"` with automatic JWT refresh.
|
||||
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
|
||||
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
|
||||
|
||||
## XML docs enforced
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
@code {
|
||||
bool _initialized;
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
protected async override Task OnInitializedAsync() {
|
||||
await loginService.InitializeAsync();
|
||||
_initialized = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject MilkStream.Client.Services.LoginService LoginService
|
||||
@inject LoginService LoginService
|
||||
@implements IDisposable
|
||||
|
||||
<NavMenu/>
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class AlbumService(
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
url += $"&search={Uri.EscapeDataString(search)}";
|
||||
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync(url));
|
||||
var response = await Client.GetAsync(url);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<List<AlbumPreviewDto>>();
|
||||
@@ -38,7 +38,7 @@ public sealed class AlbumService(
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <returns>The full album details, or null if not found.</returns>
|
||||
public async Task<AlbumFullDto?> GetAlbumAsync(Guid id) {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/album/{id}"));
|
||||
var response = await Client.GetAsync($"/api/album/{id}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<AlbumFullDto>();
|
||||
@@ -51,7 +51,7 @@ public sealed class AlbumService(
|
||||
/// </summary>
|
||||
/// <returns>A list of unassigned album previews, or null if the request failed.</returns>
|
||||
public async Task<List<AlbumPreviewDto>?> GetUnassignedAsync() {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/album?unassigned=true&pageSize=999"));
|
||||
var response = await Client.GetAsync("/api/album?unassigned=true&pageSize=999");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<List<AlbumPreviewDto>>();
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class AssetService(
|
||||
if (seed.HasValue)
|
||||
url += $"&seed={seed.Value}";
|
||||
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync(url));
|
||||
var response = await Client.GetAsync(url);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<List<AssetPreviewDto>>();
|
||||
|
||||
51
MilkStream.Client/Services/AuthServiceBase.cs
Normal file
51
MilkStream.Client/Services/AuthServiceBase.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MilkStream.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract class for services that require authentication. Builds on top of ServiceBase.
|
||||
/// </summary>
|
||||
public abstract class AuthServiceBase : ServiceBase {
|
||||
/// <summary>
|
||||
/// Gets the HTTP client for making API requests.
|
||||
/// </summary>
|
||||
protected override HttpClient Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the login service for authentication operations.
|
||||
/// </summary>
|
||||
protected LoginService LoginService { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthServiceBase"/> class with the specified options, HTTP client factory, and login service connection.
|
||||
/// </summary>
|
||||
/// <param name="options">Service configuration options.</param>
|
||||
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param>
|
||||
/// <param name="loginService">The login service for authentication.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
protected AuthServiceBase(
|
||||
IOptions<ServiceOptions> options,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
LoginService loginService,
|
||||
ILogger logger
|
||||
) : base(options, httpClientFactory, logger) {
|
||||
LoginService = loginService;
|
||||
Client = httpClientFactory.CreateClient("MilkstreamClient");
|
||||
Client.BaseAddress = new Uri(options.Value.BaseUrl);
|
||||
// Subscribe to the AuthInfoChanged event to set the authorization header when the auth info changes.
|
||||
loginService.AuthInfoChanged += SetAuthorizationHeader;
|
||||
// Set the initial authorization header and base address.
|
||||
SetAuthorizationHeader(null, loginService.AuthInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the AuthInfoChanged event to update the Authorization header for the HTTP client.
|
||||
/// </summary>
|
||||
/// <param name="sender"></param>
|
||||
/// <param name="authInfo"></param>
|
||||
protected virtual void SetAuthorizationHeader(object? sender, AuthInfo? authInfo) {
|
||||
if (authInfo == null) //no authentication available = logged out
|
||||
Client.DefaultRequestHeaders.Authorization = null;
|
||||
else Client.DefaultRequestHeaders.Authorization = new("Bearer", authInfo.Token);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public sealed class FoldersService(
|
||||
/// </summary>
|
||||
/// <returns>A list of folders, or null if the request failed.</returns>
|
||||
public async Task<List<FolderFullDto>?> GetAllFolders() {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/folder"));
|
||||
var response = await Client.GetAsync("/api/folder");
|
||||
|
||||
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<List<FolderFullDto>>();
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class FoldersService(
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <returns>The folder details, or null if not found.</returns>
|
||||
public async Task<FolderFullDto?> GetFolderById(Guid id) {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/folder/{id}"));
|
||||
var response = await Client.GetAsync($"/api/folder/{id}");
|
||||
|
||||
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FolderFullDto>();
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed class FoldersService(
|
||||
/// <param name="count">Number of sample paths to return (default 10).</param>
|
||||
/// <returns>A sample paths DTO, or null if the request failed.</returns>
|
||||
public async Task<FolderSamplePathsDto?> GetSamplePathsAsync(Guid folderId, int count = 10) {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/folder/{folderId}/sample-paths?count={count}"));
|
||||
var response = await Client.GetAsync($"/api/folder/{folderId}/sample-paths?count={count}");
|
||||
|
||||
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FolderSamplePathsDto>();
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class JobsService(
|
||||
/// Returns active root jobs only (lightweight, polled frequently).
|
||||
/// </summary>
|
||||
public async Task<List<JobStatusDto>> GetActiveRootJobs() {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/jobs"));
|
||||
var response = await Client.GetAsync("/api/jobs");
|
||||
response.EnsureSuccessStatusCode();
|
||||
var jobs = await response.Content.ReadFromJsonAsync<List<JobStatusDto>>();
|
||||
return jobs ?? new List<JobStatusDto>();
|
||||
@@ -28,7 +28,7 @@ public sealed class JobsService(
|
||||
/// Returns a page of past root jobs.
|
||||
/// </summary>
|
||||
public async Task<(List<JobStatusDto> Jobs, int Total)> GetPastRootJobs(int page, int pageSize) {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/jobs/past?page={page}&pageSize={pageSize}"));
|
||||
var response = await Client.GetAsync($"/api/jobs/past?page={page}&pageSize={pageSize}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<PastJobsResponse>();
|
||||
return (result?.Jobs ?? [], result?.Total ?? 0);
|
||||
@@ -38,7 +38,7 @@ public sealed class JobsService(
|
||||
/// Returns children of a specific parent job.
|
||||
/// </summary>
|
||||
public async Task<List<JobStatusDto>> GetChildren(Guid parentId) {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/jobs/{parentId}/children"));
|
||||
var response = await Client.GetAsync($"/api/jobs/{parentId}/children");
|
||||
response.EnsureSuccessStatusCode();
|
||||
var jobs = await response.Content.ReadFromJsonAsync<List<JobStatusDto>>();
|
||||
return jobs ?? new List<JobStatusDto>();
|
||||
|
||||
@@ -154,7 +154,7 @@ public sealed class LoginService : ServiceBase {
|
||||
var info = authResult!.ToAuthInfo();
|
||||
AuthInfo = info;
|
||||
await PersistAuthAsync(info);
|
||||
LoggedUser = await FetchLoggedUserAsync(maxRetries: 1);
|
||||
LoggedUser = await FetchLoggedUserAsync();
|
||||
return (true, null, info);
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ public sealed class LoginService : ServiceBase {
|
||||
NotifyAuthInfoChanged();
|
||||
|
||||
if (LoggedUser == null)
|
||||
LoggedUser = await FetchLoggedUserAsync(maxRetries: 1);
|
||||
LoggedUser = await FetchLoggedUserAsync();
|
||||
|
||||
return authInfo;
|
||||
}
|
||||
@@ -264,26 +264,19 @@ public sealed class LoginService : ServiceBase {
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
async Task<UserInfoDto?> FetchLoggedUserAsync(int maxRetries = 3) {
|
||||
async Task<UserInfoDto?> FetchLoggedUserAsync() {
|
||||
if (AuthInfo?.UserId == null || string.IsNullOrEmpty(AuthInfo.Token))
|
||||
return null;
|
||||
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/user/{AuthInfo.UserId}");
|
||||
request.Headers.Authorization = new("Bearer", AuthInfo.Token);
|
||||
var response = await Client.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
|
||||
} catch {
|
||||
logger.LogWarning("FetchLoggedUserAsync attempt {Attempt} failed", attempt + 1);
|
||||
}
|
||||
|
||||
if (attempt < maxRetries - 1)
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300 * (attempt + 1)));
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/user/{AuthInfo.UserId}");
|
||||
request.Headers.Authorization = new("Bearer", AuthInfo.Token);
|
||||
var response = await Client.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
|
||||
else {
|
||||
var info = await Reauthenticate();
|
||||
return info != null ? await FetchLoggedUserAsync() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async Task PersistAuthAsync(AuthInfo auth) {
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class PersonService(
|
||||
/// </summary>
|
||||
/// <returns>A list of person previews, or null if the request failed.</returns>
|
||||
public async Task<List<PersonPreviewDto>?> GetAllAsync() {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/person"));
|
||||
var response = await Client.GetAsync("/api/person");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<List<PersonPreviewDto>>();
|
||||
@@ -37,7 +37,7 @@ public sealed class PersonService(
|
||||
if (albumPage.HasValue && albumPageSize.HasValue)
|
||||
url += $"?page={albumPage}&pageSize={albumPageSize}";
|
||||
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync(url));
|
||||
var response = await Client.GetAsync(url);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<PersonDetailedDto>();
|
||||
@@ -61,7 +61,7 @@ public sealed class PersonService(
|
||||
/// <param name="dto">The person creation data.</param>
|
||||
/// <returns>The new person's ID, or null if the request failed.</returns>
|
||||
public async Task<Guid?> CreatePersonAsync(PersonCreateDto dto) {
|
||||
var response = await SendWithRefreshAsync(() => Client.PutAsJsonAsync("/api/person", dto));
|
||||
var response = await Client.PutAsJsonAsync("/api/person", dto);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<Guid>();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net;
|
||||
|
||||
namespace MilkStream.Client.Services;
|
||||
|
||||
@@ -41,71 +40,4 @@ public abstract class ServiceBase {
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Abstract class for services that require authentication. Builds on top of ServiceBase.
|
||||
/// </summary>
|
||||
public abstract class AuthServiceBase : ServiceBase {
|
||||
/// <summary>
|
||||
/// Gets the HTTP client for making API requests.
|
||||
/// </summary>
|
||||
protected override HttpClient Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the login service for authentication operations.
|
||||
/// </summary>
|
||||
protected LoginService LoginService { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthServiceBase"/> class with the specified options, HTTP client factory, and login service connection.
|
||||
/// </summary>
|
||||
/// <param name="options">Service configuration options.</param>
|
||||
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param>
|
||||
/// <param name="loginService">The login service for authentication.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
protected AuthServiceBase(
|
||||
IOptions<ServiceOptions> options,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
LoginService loginService,
|
||||
ILogger logger
|
||||
) : base(options, httpClientFactory, logger) {
|
||||
LoginService = loginService;
|
||||
Client = httpClientFactory.CreateClient("MilkstreamClient");
|
||||
Client.BaseAddress = new Uri(options.Value.BaseUrl);
|
||||
// Subscribe to the AuthInfoChanged event to set the authorization header when the auth info changes.
|
||||
loginService.AuthInfoChanged += SetAuthorizationHeader;
|
||||
// Set the initial authorization header and base address.
|
||||
SetAuthorizationHeader(null, loginService.AuthInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request, transparently refreshing the auth token and retrying once if the server returns 401.
|
||||
/// </summary>
|
||||
/// <param name="send">A factory that creates and sends the request. Called again on retry.</param>
|
||||
/// <returns>The HTTP response message.</returns>
|
||||
protected async Task<HttpResponseMessage> SendWithRefreshAsync(Func<Task<HttpResponseMessage>> send) {
|
||||
var response = await send();
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized)
|
||||
return response;
|
||||
|
||||
response.Dispose();
|
||||
var auth = await LoginService.Reauthenticate();
|
||||
if (auth == null)
|
||||
return new HttpResponseMessage(HttpStatusCode.Unauthorized);
|
||||
|
||||
Client.DefaultRequestHeaders.Authorization = new("Bearer", auth.Token);
|
||||
return await send();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the AuthInfoChanged event to update the Authorization header for the HTTP client.
|
||||
/// </summary>
|
||||
/// <param name="sender"></param>
|
||||
/// <param name="authInfo"></param>
|
||||
protected virtual void SetAuthorizationHeader(object? sender, AuthInfo? authInfo) {
|
||||
if (authInfo == null) //no authentication available = logged out
|
||||
Client.DefaultRequestHeaders.Authorization = null;
|
||||
else Client.DefaultRequestHeaders.Authorization = new("Bearer", authInfo.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public sealed class SettingsService : AuthServiceBase {
|
||||
/// </summary>
|
||||
/// <returns>A list of settings, or null if the request failed.</returns>
|
||||
public async Task<List<SettingDto>?> GetAllSettings() {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync("api/settings"));
|
||||
var response = await Client.GetAsync("api/settings");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<List<SettingDto>>();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class StatsService(
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="StatsDto"/> with all gathered statistics, or null if the request failed.</returns>
|
||||
public async Task<StatsDto?> GetStatsAsync() {
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/stats"));
|
||||
var response = await Client.GetAsync("/api/stats");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<StatsDto>();
|
||||
|
||||
Reference in New Issue
Block a user