Compare commits
5 Commits
c00f9f505e
...
3ade5c8c64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ade5c8c64 | ||
|
|
7b472f337f | ||
|
|
3630291ad9 | ||
|
|
8265c7c051 | ||
|
|
f4773ec096 |
@@ -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")]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.SassCompiler" Version="1.89.2" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"DetailedErrors": true,
|
||||
"BaseUrl": "http://localhost:5162/"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user