VoterKeyResolver: the one reader and writer of the bifrost_voter device cookie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a35119362a
commit
af3b712b26
3 changed files with 61 additions and 0 deletions
|
|
@ -24,4 +24,8 @@ public sealed class ConceptVoteOptions
|
|||
// https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator
|
||||
// refuses anything else outside Development and Testing, because "*" would allow every origin.
|
||||
public string[] AllowedOrigins { get; init; } = [];
|
||||
|
||||
// The Mill page the vote page links back to; the concept's key is appended as the anchor. Production is the
|
||||
// Aetheria site's page, so a deploy overrides nothing unless the page moves (ConceptVotes__MillPageUrl).
|
||||
public string MillPageUrl { get; init; } = "https://aetheria.gamecult.org/Game-Design/Concept-Mill";
|
||||
}
|
||||
|
|
|
|||
55
src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs
Normal file
55
src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
||||
// The only reader and writer of the bifrost_voter cookie. It supplies a device id and nothing else: VoterIdentity.For
|
||||
// decides the voter key and class. The cookie holds a protected random id, never a count, class or account.
|
||||
public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection)
|
||||
{
|
||||
public const string CookieName = "bifrost_voter";
|
||||
public const string CookiePath = "/mill";
|
||||
public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365);
|
||||
|
||||
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
|
||||
|
||||
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
|
||||
|
||||
// The device id, or null when there is no cookie or it does not unprotect under this key ring and purpose
|
||||
// (tampered, truncated, a bare id, or minted elsewhere). Nothing about a refused value is kept or reported.
|
||||
public string? TryRead(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || string.IsNullOrEmpty(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return protector.Unprotect(token);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sets a fresh device cookie on this response and returns its device id. Called only from a cast.
|
||||
public string Mint(HttpContext context)
|
||||
{
|
||||
var deviceId = Guid.NewGuid().ToString("N");
|
||||
context.Response.Cookies.Append(
|
||||
CookieName,
|
||||
protector.Protect(deviceId),
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = CookiePath,
|
||||
MaxAge = CookieLifetime,
|
||||
IsEssential = true
|
||||
});
|
||||
return deviceId;
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ builder.Services.AddRazorPages(options =>
|
|||
{
|
||||
options.Conventions.AuthorizePage("/Membership/Status");
|
||||
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
|
||||
options.Conventions.AllowAnonymousToFolder("/Mill");
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
|
|
@ -103,6 +104,7 @@ builder.Services.AddScoped<PatronageService>();
|
|||
builder.Services.AddScoped<StripeWebhookService>();
|
||||
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
|
||||
builder.Services.AddScoped<ConceptAppraisalService>();
|
||||
builder.Services.AddScoped<VoterKeyResolver>();
|
||||
builder.Services.AddScoped<ReadinessService>();
|
||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue