Adds GET /api/auth/register endpoint to check registration status. Register page now pre-checks on load and shows EmptyState instead of the form when registration is disabled. Fixes #149.
340 lines
12 KiB
C#
340 lines
12 KiB
C#
using System.Net;
|
|
using Blazored.LocalStorage;
|
|
using Butter.Dtos;
|
|
using Butter.Dtos.User;
|
|
using Butter.Types;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace MilkStream.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Handles user authentication — login, registration, logout, and token refresh.
|
|
/// </summary>
|
|
public sealed class LoginService : ServiceBase {
|
|
#region AuthInfo
|
|
|
|
/// <summary>
|
|
/// Gets or sets the current authentication info. Fires <see cref="AuthInfoChanged"/> on set.
|
|
/// </summary>
|
|
public AuthInfo? AuthInfo {
|
|
get {
|
|
return authInfo ?? null;
|
|
}
|
|
set {
|
|
authInfo = value;
|
|
NotifyAuthInfoChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fired when the authentication info changes.
|
|
/// </summary>
|
|
public event EventHandler<AuthInfo?>? AuthInfoChanged;
|
|
|
|
void NotifyAuthInfoChanged() => AuthInfoChanged?.Invoke(this, AuthInfo);
|
|
|
|
AuthInfo? authInfo;
|
|
|
|
#endregion
|
|
|
|
#region LoggedUserInfo
|
|
|
|
UserInfoDto? loggedUser;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the currently logged-in user's info. Fires <see cref="LoggedUserChanged"/> on set.
|
|
/// </summary>
|
|
public UserInfoDto? LoggedUser {
|
|
get => loggedUser ?? null;
|
|
set {
|
|
loggedUser = value;
|
|
NotifyLoggedUserChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fired when the logged-in user's info changes.
|
|
/// </summary>
|
|
public event EventHandler<UserInfoDto?>? LoggedUserChanged;
|
|
|
|
void NotifyLoggedUserChanged() => LoggedUserChanged?.Invoke(this, LoggedUser);
|
|
|
|
/// <summary>
|
|
/// Gets whether a user is currently logged in.
|
|
/// </summary>
|
|
public bool IsLoggedIn => LoggedUser != null;
|
|
|
|
/// <summary>
|
|
/// Gets whether the logged-in user is an admin.
|
|
/// </summary>
|
|
public bool IsAdmin => LoggedUser?.AccessLevel == EAccessLevel.Admin;
|
|
|
|
/// <summary>
|
|
/// Gets whether the logged-in user is an admin or curator (no maintainer scope).
|
|
/// </summary>
|
|
public bool IsAdminOrCurator => LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
|
|
|
/// <summary>
|
|
/// Gets whether the logged-in user has full edit access (admin, curator, or any maintainer).
|
|
/// </summary>
|
|
public bool CanEditAll => LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator or EAccessLevel.Maintainer;
|
|
|
|
/// <summary>
|
|
/// Checks whether the logged-in user can edit a specific person's content.
|
|
/// Admins and curators always have access. Maintainers must be assigned to that person.
|
|
/// </summary>
|
|
/// <param name="personId">The person ID to check, or null to disallow maintainer access.</param>
|
|
public bool CanEdit(Guid? personId) {
|
|
if (IsAdminOrCurator) return true;
|
|
if (LoggedUser?.AccessLevel == EAccessLevel.Maintainer)
|
|
return personId.HasValue && LoggedUser.MaintainedPersonIds?.Contains(personId.Value) == true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the reason the user was force-logged out (e.g. ban), if any.
|
|
/// </summary>
|
|
public string? ForceLogoutReason { get; set; }
|
|
|
|
/// <summary>
|
|
/// Fired when the user is force-logged out (banned, disabled, etc.).
|
|
/// </summary>
|
|
public event Action<string>? ForceLogout;
|
|
|
|
#endregion
|
|
|
|
readonly ILogger<LoginService> logger;
|
|
readonly ILocalStorageService localStorage;
|
|
|
|
/// <summary>
|
|
/// Initialises a new instance of the <see cref="LoginService"/> class.
|
|
/// </summary>
|
|
/// <param name="options">Service configuration options.</param>
|
|
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param>
|
|
/// <param name="logger">Logger instance.</param>
|
|
/// <param name="localStorage">Browser local storage for persisting auth tokens.</param>
|
|
public LoginService(
|
|
IOptions<ServiceOptions> options,
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<LoginService> logger,
|
|
ILocalStorageService localStorage
|
|
) : base(options, httpClientFactory, logger) {
|
|
this.logger = logger;
|
|
this.localStorage = localStorage;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores authentication state from local storage and fetches the user profile.
|
|
/// Returns <c>true</c> if the user is fully authenticated, <c>false</c> otherwise.
|
|
/// </summary>
|
|
public async Task<bool> InitializeAsync() {
|
|
AuthInfo? auth = null;
|
|
try {
|
|
auth = await localStorage.GetItemAsync<AuthInfo>("auth");
|
|
} catch {
|
|
try { await localStorage.RemoveItemAsync("auth"); } catch { }
|
|
}
|
|
|
|
if (auth == null)
|
|
return false;
|
|
|
|
AuthInfo = auth;
|
|
LoggedUser = await FetchLoggedUserAsync();
|
|
|
|
if (LoggedUser is { IsBanned: true }) {
|
|
ForceLogoutReason = "User is banned";
|
|
await Logout();
|
|
return false;
|
|
}
|
|
|
|
if (LoggedUser is { DeletedAt: not null }) {
|
|
ForceLogoutReason = "User is disabled";
|
|
await Logout();
|
|
return false;
|
|
}
|
|
|
|
return IsLoggedIn;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates the user with the given username and password.
|
|
/// </summary>
|
|
/// <param name="username">The username or email.</param>
|
|
/// <param name="password">The password.</param>
|
|
/// <returns>A tuple with success state, optional error message, and auth info.</returns>
|
|
public async Task<(bool success, string? error, AuthInfo? authInfo)> Login(string username, string password) {
|
|
logger.LogInformation(
|
|
"Attempting to log in with username: {Username} password: {S}",
|
|
username,
|
|
new string('*', password.Length)
|
|
);
|
|
|
|
var loginDto = new CredentialsDto() {
|
|
Identifier = username,
|
|
Password = password
|
|
};
|
|
|
|
var response = await Client.PostAsJsonAsync("api/auth/login", loginDto);
|
|
logger.LogInformation("Result: {ResponseStatusCode}", response.StatusCode);
|
|
|
|
if (response.IsSuccessStatusCode) {
|
|
var authResult = await response.Content.ReadFromJsonAsync<AuthResultDto>();
|
|
var info = authResult!.ToAuthInfo();
|
|
AuthInfo = info;
|
|
await PersistAuthAsync(info);
|
|
LoggedUser = await FetchLoggedUserAsync();
|
|
return (true, null, info);
|
|
}
|
|
|
|
var error = "Login failed. Please try again.";
|
|
try {
|
|
var failureResult = await response.Content.ReadFromJsonAsync<AuthResultDto>();
|
|
if (!string.IsNullOrEmpty(failureResult?.ErrorMessage))
|
|
error = failureResult.ErrorMessage;
|
|
} catch { /* use default message */ }
|
|
|
|
return (false, error, null);
|
|
}
|
|
|
|
readonly SemaphoreSlim _refreshLock = new(1, 1);
|
|
|
|
/// <summary>
|
|
/// Attempts to refresh the access token using the stored refresh token.
|
|
/// </summary>
|
|
/// <returns>The updated auth info, or null if refresh failed.</returns>
|
|
public async Task<AuthInfo?> Reauthenticate() {
|
|
await _refreshLock.WaitAsync();
|
|
try {
|
|
if (authInfo == null) { logger.LogInformation("Auth info is null, skipping..."); }
|
|
|
|
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)authInfo?.UserId!,
|
|
RefreshToken = authInfo?.RefreshToken!
|
|
};
|
|
|
|
var result = await Client.PostAsync("api/auth/refresh", JsonContent.Create(refreshDto)).ConfigureAwait(false);
|
|
logger.LogInformation("Result: {ResultStatusCode}", result.StatusCode);
|
|
|
|
if (result.IsSuccessStatusCode) {
|
|
var authResult = await result.Content.ReadFromJsonAsync<AuthResultDto>();
|
|
authInfo!.Token = authResult?.Token;
|
|
authInfo.RefreshToken = authResult?.RefreshToken ?? authInfo.RefreshToken;
|
|
NotifyAuthInfoChanged();
|
|
|
|
if (LoggedUser == null)
|
|
LoggedUser = await FetchLoggedUserAsync();
|
|
|
|
return authInfo;
|
|
}
|
|
|
|
await HandleRefreshFailure(result);
|
|
|
|
return null;
|
|
} finally {
|
|
_refreshLock.Release();
|
|
}
|
|
}
|
|
|
|
async Task HandleRefreshFailure(HttpResponseMessage response) {
|
|
try {
|
|
var failureResult = await response.Content.ReadFromJsonAsync<AuthResultDto>().ConfigureAwait(false);
|
|
var error = failureResult?.ErrorMessage;
|
|
|
|
if (error is "User is banned" or "User is disabled") {
|
|
ForceLogoutReason = error;
|
|
await Logout();
|
|
ForceLogout?.Invoke(error);
|
|
return;
|
|
}
|
|
} catch { /* best-effort */ }
|
|
|
|
ForceLogoutReason = "Session expired. Please log in again.";
|
|
await Logout();
|
|
ForceLogout?.Invoke(ForceLogoutReason);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a new user account.
|
|
/// </summary>
|
|
/// <param name="username">The desired username.</param>
|
|
/// <param name="email">The email address.</param>
|
|
/// <param name="password">The password.</param>
|
|
/// <returns>HttpStatusCode indicating the result (200 = success, 403 = disabled, 409 = conflict).</returns>
|
|
public async Task<HttpStatusCode> Register(string username, string email, string password) {
|
|
logger.LogInformation("Attempting to register user with username: {Username}", username);
|
|
|
|
var registerDto = new UserRegisterDto() {
|
|
Username = username,
|
|
Email = email,
|
|
Password = password
|
|
};
|
|
|
|
var response = await Client.PostAsJsonAsync("api/auth/register", registerDto);
|
|
logger.LogInformation("Result: {ResponseStatusCode}", response.StatusCode);
|
|
return response.StatusCode;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logs out the current user by invalidating the refresh token on the server and clearing local state.
|
|
/// </summary>
|
|
public async Task Logout() {
|
|
logger.LogInformation("Logging out current user with ID: {AuthInfoUserId}", authInfo?.UserId);
|
|
try {
|
|
await Client.PostAsync("api/auth/logout", null);
|
|
} catch { /* best-effort — clear local state regardless */ }
|
|
AuthInfo = null;
|
|
LoggedUser = null;
|
|
try {
|
|
await localStorage.RemoveItemAsync("auth");
|
|
} catch { }
|
|
logger.LogInformation("Logout completed");
|
|
}
|
|
|
|
#region Private Helpers
|
|
|
|
async Task<UserInfoDto?> FetchLoggedUserAsync() {
|
|
if (AuthInfo?.UserId == null || string.IsNullOrEmpty(AuthInfo.Token))
|
|
return null;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
async Task PersistAuthAsync(AuthInfo auth) {
|
|
try {
|
|
await localStorage.SetItemAsync("auth", auth);
|
|
} catch {
|
|
logger.LogWarning("Failed to persist auth info to local storage");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether user registration is currently enabled on the server.
|
|
/// </summary>
|
|
/// <returns>True if registration is allowed.</returns>
|
|
public async Task<bool> IsRegistrationEnabledAsync() {
|
|
try {
|
|
var response = await Client.GetAsync("api/auth/register");
|
|
return response.StatusCode == HttpStatusCode.OK;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|