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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
|
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
namespace Progmasoft.Progmaweb.Api.Accounts;
/// <summary>Settings of account sessions, bound from the <c>AccountSessions</c> configuration section.</summary>
internal sealed class AccountSessionOptions
{
/// <summary>Name of the configuration section.</summary>
public const string SectionName = "AccountSessions";
/// <summary>The longest a session may stay valid: 30 days.</summary>
public const int MaximumLifetimeHours = 30 * 24;
/// <summary>
/// Hours a session stays valid, 30 days unless configured. Values outside 1 to
/// <see cref="MaximumLifetimeHours"/> are clamped to that range.
/// </summary>
public int LifetimeHours { get; init; } = MaximumLifetimeHours;
/// <summary>
/// Name of the session cookie. The <c>__Host-</c> prefix binds it to this host over HTTPS; a cookie shared with
/// <see cref="CookieDomain"/> takes the <c>__Secure-</c> prefix instead, which browsers allow with a domain.
/// </summary>
public string CookieName { get; init; } = "__Host-ProgmasoftSession";
/// <summary>
/// The domain the session cookie is shared with, such as <c>progmasoft.com</c>, or <see langword="null"/> for a
/// cookie of the account host alone. Production shares it so that the Progmasoft Console on
/// <c>console.progmasoft.com</c> knows the signed-in account without a sign-in of its own.
/// </summary>
public string? CookieDomain { get; init; }
}
/// <summary>Opens, checks and revokes account sessions.</summary>
/// <remarks>
/// A session is a random 256-bit token that only the browser holds. The store keeps its SHA-256 digest, so a copy
/// of the store does not reveal a usable token.
/// </remarks>
/// <param name="store">Store that keeps the session digests.</param>
/// <param name="timeProvider">Clock used for creation and expiry.</param>
/// <param name="options">Session settings.</param>
internal sealed class SessionService(
IAccountStore store,
TimeProvider timeProvider,
IOptions<AccountSessionOptions> options)
{
/// <summary>The session settings in effect.</summary>
private readonly AccountSessionOptions settings = options.Value;
/// <summary>Opens a session for an account.</summary>
/// <param name="account">The account that signed in.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns>The account with the new session token and its expiry.</returns>
public async ValueTask<AuthenticatedAccount> CreateAsync(AccountRecord account, CancellationToken cancellationToken)
{
byte[] tokenBytes = RandomNumberGenerator.GetBytes(32);
string token = Convert.ToBase64String(tokenBytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
byte[] digest = SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(token));
DateTimeOffset createdAt = timeProvider.GetUtcNow();
DateTimeOffset expiresAt = createdAt.AddHours(Math.Clamp(settings.LifetimeHours, 1, AccountSessionOptions.MaximumLifetimeHours));
await store.StoreSessionAsync(new SessionRecord(digest, account.Id, createdAt, expiresAt), cancellationToken);
return new AuthenticatedAccount(account, token, expiresAt);
}
/// <summary>Resolves a session token to its account.</summary>
/// <param name="token">The token from the session cookie.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns>
/// The account, or <see langword="null"/> when the token is malformed, unknown or expired. An expired session is
/// revoked on the way.
/// </returns>
public async ValueTask<AccountRecord?> AuthenticateAsync(string? token, CancellationToken cancellationToken)
{
if (!TryDigest(token, out byte[] digest))
{
return null;
}
SessionRecord? session = await store.FindSessionAsync(digest, cancellationToken);
if (session is null)
{
return null;
}
if (session.ExpiresAt <= timeProvider.GetUtcNow())
{
await store.RevokeSessionAsync(digest, cancellationToken);
return null;
}
return await store.FindByIdAsync(session.AccountId, cancellationToken);
}
/// <summary>Ends the session of a token. A malformed or unknown token is ignored.</summary>
/// <param name="token">The token from the session cookie.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns>A task that completes when the session is gone.</returns>
public async ValueTask RevokeAsync(string? token, CancellationToken cancellationToken)
{
if (TryDigest(token, out byte[] digest))
{
await store.RevokeSessionAsync(digest, cancellationToken);
}
}
/// <summary>Creates the attributes of the session cookie.</summary>
/// <param name="expiresAt">
/// Moment the session expires; the cookie expires with it. <see langword="null"/> describes the cookie for its
/// deletion.
/// </param>
/// <returns>Options for a secure, HTTP-only, same-site cookie on the root path of the configured domain.</returns>
public CookieOptions CreateCookieOptions(DateTimeOffset? expiresAt) => new()
{
Domain = string.IsNullOrEmpty(settings.CookieDomain) ? null : settings.CookieDomain,
Expires = expiresAt,
HttpOnly = true,
IsEssential = true,
Path = "/",
SameSite = SameSiteMode.Lax,
Secure = true
};
/// <summary>Finds the account of the session cookie of a request.</summary>
/// <param name="context">The request.</param>
/// <returns>The account, or <see langword="null"/> without a valid session.</returns>
public ValueTask<AccountRecord?> AuthenticateAsync(HttpContext context)
{
context.Request.Cookies.TryGetValue(CookieName, out string? token);
return AuthenticateAsync(token, context.RequestAborted);
}
/// <summary>Name of the session cookie.</summary>
public string CookieName => settings.CookieName;
/// <summary>Computes the digest of a token that has the shape of a session token.</summary>
/// <param name="token">The candidate token.</param>
/// <param name="digest">The SHA-256 digest; empty when the token is refused.</param>
/// <returns><see langword="true"/> when the token has a plausible length and alphabet.</returns>
private static bool TryDigest(string? token, out byte[] digest)
{
digest = [];
if (token is null || token.Length is < 40 or > 64 || token.Any(character =>
!(char.IsAsciiLetterOrDigit(character) || character is '-' or '_')))
{
return false;
}
digest = SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(token));
return true;
}
}
|