using System.Net;
using Blazored.LocalStorage;
using Butter.Dtos;
using Butter.Dtos.User;
using Butter.Types;
using Microsoft.Extensions.Options;
namespace MilkStream.Client.Services;
///
/// Handles user authentication — login, registration, logout, and token refresh.
///
public sealed class LoginService : ServiceBase {
#region AuthInfo
///
/// Gets or sets the current authentication info. Fires on set.
///
public AuthInfo? AuthInfo {
get {
return authInfo ?? null;
}
set {
authInfo = value;
NotifyAuthInfoChanged();
}
}
///
/// Fired when the authentication info changes.
///
public event EventHandler? AuthInfoChanged;
void NotifyAuthInfoChanged() => AuthInfoChanged?.Invoke(this, AuthInfo);
AuthInfo? authInfo;
#endregion
#region LoggedUserInfo
UserInfoDto? loggedUser;
///
/// Gets or sets the currently logged-in user's info. Fires on set.
///
public UserInfoDto? LoggedUser {
get => loggedUser ?? null;
set {
loggedUser = value;
NotifyLoggedUserChanged();
}
}
///
/// Fired when the logged-in user's info changes.
///
public event EventHandler? LoggedUserChanged;
void NotifyLoggedUserChanged() => LoggedUserChanged?.Invoke(this, LoggedUser);
///
/// Gets whether a user is currently logged in.
///
public bool IsLoggedIn => LoggedUser != null;
///
/// Gets whether the logged-in user is an admin.
///
public bool IsAdmin => LoggedUser?.AccessLevel == EAccessLevel.Admin;
///
/// Gets whether the logged-in user is an admin or curator (no maintainer scope).
///
public bool IsAdminOrCurator => LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
///
/// Gets whether the logged-in user has full edit access (admin, curator, or any maintainer).
///
public bool CanEditAll => LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator or EAccessLevel.Maintainer;
///
/// 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.
///
/// The person ID to check, or null to disallow maintainer access.
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;
}
///
/// Gets the reason the user was force-logged out (e.g. ban), if any.
///
public string? ForceLogoutReason { get; set; }
///
/// Fired when the user is force-logged out (banned, disabled, etc.).
///
public event Action? ForceLogout;
#endregion
readonly ILogger logger;
readonly ILocalStorageService localStorage;
///
/// Initialises a new instance of the class.
///
/// Service configuration options.
/// Factory for creating HTTP clients.
/// Logger instance.
/// Browser local storage for persisting auth tokens.
public LoginService(
IOptions options,
IHttpClientFactory httpClientFactory,
ILogger logger,
ILocalStorageService localStorage
) : base(options, httpClientFactory, logger) {
this.logger = logger;
this.localStorage = localStorage;
}
///
/// Restores authentication state from local storage and fetches the user profile.
/// Returns true if the user is fully authenticated, false otherwise.
///
public async Task InitializeAsync() {
AuthInfo? auth = null;
try {
auth = await localStorage.GetItemAsync("auth");
} catch (Exception ex) {
logger.LogWarning(ex, "Failed to read auth from local storage, attempting cleanup");
try { await localStorage.RemoveItemAsync("auth"); } catch (Exception cleanupEx) {
logger.LogWarning(cleanupEx, "Failed to cleanup auth from local storage");
}
}
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;
}
///
/// Authenticates the user with the given username and password.
///
/// The username or email.
/// The password.
/// A tuple with success state, optional error message, and auth info.
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();
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();
if (!string.IsNullOrEmpty(failureResult?.ErrorMessage))
error = failureResult.ErrorMessage;
} catch (Exception ex) {
logger.LogDebug(ex, "Failed to parse login failure response, using default message");
}
return (false, error, null);
}
readonly SemaphoreSlim _refreshLock = new(1, 1);
///
/// Attempts to refresh the access token using the stored refresh token.
///
/// The updated auth info, or null if refresh failed.
public async Task 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();
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().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 (Exception ex) {
logger.LogWarning(ex, "Failed to parse refresh failure response");
}
ForceLogoutReason = "Session expired. Please log in again.";
await Logout();
ForceLogout?.Invoke(ForceLogoutReason);
}
///
/// Registers a new user account.
///
/// The desired username.
/// The email address.
/// The password.
/// HttpStatusCode indicating the result (200 = success, 403 = disabled, 409 = conflict).
public async Task 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;
}
///
/// Logs out the current user by invalidating the refresh token on the server and clearing local state.
///
public async Task Logout() {
logger.LogInformation("Logging out current user with ID: {AuthInfoUserId}", authInfo?.UserId);
try {
await Client.PostAsync("api/auth/logout", null);
} catch (Exception ex) {
logger.LogWarning(ex, "Logout API call failed, clearing local state anyway");
}
AuthInfo = null;
LoggedUser = null;
try {
await localStorage.RemoveItemAsync("auth");
} catch (Exception ex) {
logger.LogWarning(ex, "Failed to clear auth from local storage");
}
logger.LogInformation("Logout completed");
}
#region Private Helpers
async Task 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();
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");
}
}
///
/// Checks whether user registration is currently enabled on the server.
///
/// True if registration is allowed.
public async Task IsRegistrationEnabledAsync() {
try {
var response = await Client.GetAsync("api/auth/register");
return response.StatusCode == HttpStatusCode.OK;
} catch {
return false;
}
}
#endregion
}