5 Commits

Author SHA1 Message Date
Samuele Lorefice
3ade5c8c64 More logging 2025-07-16 19:44:27 +02:00
Samuele Lorefice
7b472f337f JWT Token refresher rewrite 2025-07-16 19:43:52 +02:00
Samuele Lorefice
3630291ad9 Lazy loading for NavMenu 2025-07-16 19:43:06 +02:00
Samuele Lorefice
8265c7c051 increased logging for hotreload configurations 2025-07-16 19:42:51 +02:00
Samuele Lorefice
f4773ec096 removed get-all method from settings controller 2025-07-16 19:42:40 +02:00
9 changed files with 119 additions and 65 deletions

View File

@@ -39,16 +39,6 @@ public class SettingsController(
return Ok(settingsRepository.Get().ToSettingsDto());
}
[HttpGet]
[Authorize(Roles = "Admin")]
[Route("get-all")]
public ActionResult<SettingsDto> GetAll() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
return Ok(settingsRepository.Get().ToSettingsDto());
}
[HttpPost]
[Authorize(Roles = "Admin")]

View File

@@ -28,42 +28,64 @@
<a class="nav-link" href="#">Photos</a>
</li>
</ul>
@if (!loggedIn) {
<button class="btn btn-primary mx-1" @onclick="OnLoginClick">
Login
</button>
<button class="btn btn-primary mx-1" @onclick="OnRegisterClick">
Register
</button>
} else {
<div class="d-flex">
<input class="form-control ms-1" style="border-top-right-radius: 0; border-bottom-right-radius: 0" type="search" placeholder="Search anything..." aria-label="search"/>
<button class="btn btn-outline-success me-1" style="border-top-left-radius: 0; border-bottom-left-radius: 0" type="submit">
<i class="bi bi-search-heart"></i>
@if (isConnected) {
if (!loggedIn) {
<button class="btn btn-primary mx-1" @onclick="OnLoginClick">
Login
</button>
</div>
<button class="btn btn-primary mx-1" @onclick="OnRegisterClick">
Register
</button>
} else {
<div class="d-flex">
<input class="form-control ms-1" style="border-top-right-radius: 0; border-bottom-right-radius: 0" type="search" placeholder="Search anything..." aria-label="search"/>
<button class="btn btn-outline-success me-1" style="border-top-left-radius: 0; border-bottom-left-radius: 0" type="submit">
<i class="bi bi-search-heart"></i>
</button>
</div>
@if (admin) {
<button class="btn btn-outline-info mx-1" @onclick="OnSettingsClick">
<i class="bi bi-gear-wide-connected"></i>
Settings
if (admin) {
<button class="btn btn-outline-info mx-1" @onclick="OnSettingsClick">
<i class="bi bi-gear-wide-connected"></i>
Settings
</button>
}
<div class="mx-2">@username</div>
<button class="btn btn-outline-light">
<i class="bi bi-person-circle"></i>
</button>
}
<div class="mx-2">@username</div>
<button class="btn btn-outline-light">
<i class="bi bi-person-circle"></i>
</button>
}
</div>
</div>
</div>
@code{
bool loggedIn = false;
bool admin = false;
string username = string.Empty;
bool isConnected;
bool loggedIn;
bool admin;
string username = string.Empty;
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
isConnected = true;
await LoadStateAsync();
StateHasChanged();
}
}
private async Task LoadStateAsync() {
var userInfo = await userService.GetUserAsync();
if (userInfo == null) {
loggedIn = false;
return;
}
loggedIn = true;
username = userInfo.Username;
admin = userInfo.AccessLevel == EAccessLevel.Admin;
}
/*
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (!firstRender) return; // Only run this code on the first render
@@ -75,7 +97,7 @@
username = userInfo.Username;
admin = userInfo.AccessLevel == EAccessLevel.Admin;
}
*/
async Task OnLoginClick() {
var userInfo = await userService.GetUserAsync();
if (userInfo != null) {

View File

@@ -1,5 +1,6 @@
using MilkStream.Services;
using System.Net;
using Microsoft.IdentityModel.JsonWebTokens;
namespace MilkStream;
@@ -7,18 +8,31 @@ namespace MilkStream;
/// Can be added to an HTTP client to automatically refresh JWT tokens when they expire.
/// </summary>
/// <param name="loginService">login service that handles the current identity</param>
public class JwtTokenRefresher(LoginService loginService) : DelegatingHandler {
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
//pass through the current request and get the response
HttpResponseMessage response = await base.SendAsync(request, cancellationToken);
//if the response is not unauthorized or the login service has no auth info, return the response
if (response.StatusCode != HttpStatusCode.Unauthorized || loginService.authInfo == null) return response;
//if we got a 401 Unauthorized, we need to reauthenticate
await loginService.Reauthenticate().ConfigureAwait(false);
//if reauthentication was successful, resend the request with the new token
request.Headers.Authorization = new ("bearer", loginService.authInfo.Token);
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
//whatever is the response this time, return it
return response;
public class JwtTokenRefresher(LoginService loginService, ILogger<JwtTokenRefresher> logger) : DelegatingHandler {
protected async override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
) {
var jwt = new JsonWebToken(request.Headers.Authorization?.Parameter);
// Check if the JWT is valid and not going to expire within 1 minute otherwise send the request as is.
if (jwt.ValidTo >= DateTime.UtcNow.AddMinutes(1)) {
logger.LogDebug($"JWT Token valid up to {jwt.ValidTo}, no need to refresh.");
return await base.SendAsync(request, cancellationToken);
}
logger.LogDebug($"JWT Token expired or expiring soon at {jwt.ValidTo}, refreshing token...");
// If the JWT is expired or going to expire within 1 minute, reauthenticate.
var auth = await loginService.Reauthenticate();
if (auth == null) {
// If reauthentication fails, return an unauthorized response.
logger.LogDebug("Failed to reauthenticate, returning unauthorized response.");
return new HttpResponseMessage(HttpStatusCode.Unauthorized) {
Content = new StringContent("Authentication failed. Please log in again.")
};
}
jwt = new JsonWebToken(auth.Token);
logger.LogDebug($"JWT Token refreshed, valid up to {jwt.ValidTo}.");
request.Headers.Authorization = new("bearer", auth.Token);
return await base.SendAsync(request, cancellationToken);
}
}

View File

@@ -19,6 +19,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.SassCompiler" Version="1.89.2" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -26,6 +26,7 @@ builder.Services.Configure<UserServiceOptions>(options => { options.BaseUrl = bu
//http listener for all requests with automatic JWT token refresh on 401 Unauthorized responses.
builder.Services.AddTransient<JwtTokenRefresher>();
builder.Services.AddHttpClient("MilkstreamClient").AddHttpMessageHandler<JwtTokenRefresher>();
builder.Services.AddLogging();
builder.WebHost.UseStaticWebAssets();
var app = builder.Build();

View File

@@ -11,19 +11,22 @@ public class LoginServiceOptions() {
}
public class LoginService {
HttpClient client;
HttpClient client = new HttpClient();
ProtectedLocalStorage localStorage;
public AuthInfo? authInfo { get; set; }
public bool IsLoggedIn => authInfo != null;
public LoginService(IOptions<LoginServiceOptions> options, ProtectedLocalStorage localStorage) {
client = new HttpClient();
ILogger<LoginService> logger;
public LoginService(IOptions<LoginServiceOptions> options, ProtectedLocalStorage localStorage, ILogger<LoginService> logger) {
client.BaseAddress = new Uri(options.Value.BaseUrl);
client.DefaultRequestHeaders.Accept.Add(new("application/json"));
this.localStorage = localStorage;
this.logger = logger;
logger.LogInformation($"LoginService initialized with base URL: {options.Value.BaseUrl}");
}
public async Task<bool> RetrieveAuthInfo() {
logger.LogInformation("Retrieving auth info...");
try {
var result = await localStorage.GetAsync<AuthInfo>("auth");
@@ -31,42 +34,56 @@ public class LoginService {
authInfo = result.Value;
return true;
}
} catch (Exception ex) { Console.WriteLine($"Error retrieving auth info: {ex.Message}"); }
} catch (Exception ex) { logger.LogError($"Error retrieving auth info: {ex.Message}"); }
return false;
}
public async Task<(bool, AuthInfo?)> Login(string username, string password) {
logger.LogInformation($"Attempting to log in with username: {username} password: {new string('*', password.Length)}");
var loginDto = new CredentialsDto() {
Identifier = username,
Password = password
};
var response = await client.PostAsJsonAsync("api/auth/login", loginDto);
logger.LogInformation($"Result: {response.StatusCode}");
return response.IsSuccessStatusCode ?
(true, response.Content.ReadFromJsonAsync<AuthResultDto>().Result!.ToAuthInfo()) :
(false, null);
}
public async Task<AuthInfo> Reauthenticate() {
if (authInfo == null) throw new InvalidOperationException("No authentication info available to reauthenticate.");
public async Task<AuthInfo?> Reauthenticate() {
// Ensure authInfo is loaded before attempting to refresh the token
if (authInfo == null) {
logger.LogInformation("Auth info is null, attempting to retrieve it.");
await RetrieveAuthInfo();
}
if (authInfo == null || string.IsNullOrEmpty(authInfo.RefreshToken)) {
logger.LogInformation("No auth info or refresh token available.");
return null;
}
logger.LogInformation("Refreshing auth token...");
var refreshDto = new RefreshDto() {
UserId = (Guid)this.authInfo.UserId,
RefreshToken = authInfo.RefreshToken!
UserId = (Guid)authInfo?.UserId!,
RefreshToken = authInfo?.RefreshToken!
};
var result = await client.PostAsync("api/auth/refresh", JsonContent.Create(refreshDto)).ConfigureAwait(false);
logger.LogInformation($"Result: {result.StatusCode}");
if (result.IsSuccessStatusCode) {
var authResult = await result.Content.ReadFromJsonAsync<AuthResultDto>();
authInfo.Token = authResult?.Token;
return authInfo;
} else { throw new Exception($"Failed to reauthenticate. Status code: {result.StatusCode}"); }
}
return null;
}
public async Task<bool> Register(string username, string email, string password) {
logger.LogInformation($"Attempting to register user with username: {username}, email: {email}");
var registerDto = new UserRegisterDto() {
Username = username,
Email = email,
@@ -74,13 +91,14 @@ public class LoginService {
};
var response = await client.PostAsJsonAsync("api/auth/register", registerDto);
logger.LogInformation($"Result: {response.StatusCode}");
return response.IsSuccessStatusCode;
}
public async Task<bool> Logout() {
logger.LogInformation($"Logging out current user with ID: {authInfo?.UserId}");
var response = await client.PostAsync("api/auth/logout", null);
logger.LogInformation($"Result: {response.StatusCode}");
return response.IsSuccessStatusCode;
}
}

View File

@@ -21,7 +21,7 @@ public class SettingsService {
client.DefaultRequestHeaders.Authorization = new("Bearer", loginService.authInfo!.Token);
var response = await client.GetAsync("api/settings/get-all");
var response = await client.GetAsync("api/settings");
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<SettingsDto>();

View File

@@ -1,3 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"DetailedErrors": true,
"BaseUrl": "http://localhost:5162/"
}

View File

@@ -32,6 +32,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonTypeInfo_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F56f6e9e034798a655b8f21962f889697e28a362db6cbda998acf4fd93f7d43_003FJsonTypeInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJSRuntime_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc2281a8d864b65f58c41ec4f1f111ce7b4fce97a2f5d4f63df0b3562604cf9_003FJSRuntime_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALayoutComponentBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F008cfbc9ad2845918aee405756261491c9910_003F3d_003Ffb517672_003FLayoutComponentBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALazy_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F43c07a384250af318144a0dcb061248bfe4d85ec1de7be8ea536923238d746_003FLazy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F4363be3555734c96804bc429c863e56ab2e200_003F2f_003F2c688ff0_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2b90705926ae4f10aa01bbe3f1025828c90908_003F1a_003F5b817523_003FMonitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>