MailSharp / MailSharp.MailClient / Services / SessionAccountManager.cs
Code · 92 lines · 3651 bytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92using 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<int>(GetLoggedInAccountIds()) { accountId };
		return SignInAsync(ids, accountId);
	}

	public Task RemoveAsync(int accountId)
	{
		var ids = new HashSet<int>(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<int> 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<int> loggedInIds, int activeId)
	{
		var claims = new List<Claim> { 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)
		});
	}
}