using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using System.Security.Claims; namespace MailSharp.MailClient.Services; // Tracks which accounts are "logged in" and which one is active via claims baked into the // persistent auth cookie (not server-side session state), so login survives an app/server // restart as long as the Data Protection keys used to protect that cookie are also persisted // to disk. IMAP passwords are never stored here or in the cookie - they're decrypted on demand // from the LiteDB-backed account store (see IAccountStore.GetPlainPassword). public class SessionAccountManager(IHttpContextAccessor accessor, IAccountStore accountStore) { private const string LoggedInClaimType = "mailsharp:logged_in_account"; private const string ActiveClaimType = "mailsharp:active_account"; public const string AdminClaimType = "mailsharp:is_admin"; private HttpContext Context => accessor.HttpContext!; public Task AddOrUpdateAsync(int accountId) { var ids = new HashSet(GetLoggedInAccountIds()) { accountId }; return SignInAsync(ids, accountId); } public Task RemoveAsync(int accountId) { var ids = new HashSet(GetLoggedInAccountIds()); ids.Remove(accountId); if (ids.Count == 0) { return Context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } var active = GetActiveAccountId(); var newActive = active == accountId ? ids.First() : active ?? ids.First(); return SignInAsync(ids, newActive); } public Task SetActiveAccountIdAsync(int accountId) { var ids = GetLoggedInAccountIds(); return ids.Contains(accountId) ? SignInAsync(ids, accountId) : Task.CompletedTask; } public Task LogoutAsync() => Context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); // Requires accountId to be one of *this* cookie's logged-in accounts before decrypting - // otherwise any caller-supplied account id would let a different session send mail as an // account it never authenticated as. public string? GetPassword(int accountId) { if (!GetLoggedInAccountIds().Contains(accountId)) return null; var account = accountStore.Get(accountId); return account == null ? null : accountStore.GetPlainPassword(account); } public List GetLoggedInAccountIds() => [.. Context.User.FindAll(LoggedInClaimType).Select(c => int.Parse(c.Value))]; public int? GetActiveAccountId() { var claim = Context.User.FindFirst(ActiveClaimType); return claim != null && int.TryParse(claim.Value, out var id) ? id : null; } public bool IsLoggedIn => Context.User.Identity?.IsAuthenticated == true && GetLoggedInAccountIds().Count > 0; private Task SignInAsync(IEnumerable loggedInIds, int activeId) { var claims = new List { new(ActiveClaimType, activeId.ToString()) }; claims.AddRange(loggedInIds.Select(id => new Claim(LoggedInClaimType, id.ToString()))); // The admin claim tracks whichever account is currently active - re-computed here every time // SignInAsync runs (login, switch, add/remove), so switching away from an admin account drops // Maintenance-page access immediately without requiring a full logout. if (accountStore.Get(activeId)?.IsAdmin == true) { claims.Add(new Claim(AdminClaimType, "true")); } var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); return Context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddHours(24) }); } }