diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index da13c4a..a5e0781 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -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"; } diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs new file mode 100644 index 0000000..ce9fbec --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs @@ -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; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bea0beb..2101abb 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton();