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 }