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)
});
}
}