Files
MilkyShots/MilkStream.Client/Services/LoginService.cs
T
REDCODE 785d33e289 fix: hide register form when registration is disabled
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.
2026-07-21 14:14:18 +02:00

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
}