Files
SVSimServer/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs
gamer147 1960e28298 refactor(auth): decouple Steam handler from request DTO shape
Translation middleware now extracts viewer_id/steam_id/steam_session_ticket
from the decrypted msgpack dict into HttpContext.Items before the typed
DTO deserialize. The Steam handler reads from there instead of re-parsing
Request.Body — so authed action DTOs no longer need to inherit BaseRequest
to keep the auth fields alive through the msgpack→DTO→JSON pivot.

Retires the recurring footgun documented in
docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md
(2026-05-25 basic-puzzle, 2026-05-28 deck-code, 2026-06-02 Phase 3 Bot,
2026-06-10 profile/index + item_acquire_history/info + user_mypage/update).

Pinned by AuthDecouplingTests — posts an encrypted msgpack body to
/profile/index (DTO does not inherit BaseRequest) through the real
translation middleware + auth handler and asserts 200. Adds an
EncryptedMsgpackHelper + useRealAuthHandler factory flag, reusable for
future wire-shape tests.

ProfileIndexRequest, ItemAcquireHistoryInfoRequest, and
UserMyPageUpdateRequest revert to the naked shape — the per-DTO
workarounds become vestigial under the new architecture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 12:29:10 -04:00

127 lines
6.7 KiB
C#

using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuthenticationHandlerOptions>
{
private readonly SteamSessionService _sessionService;
private readonly IViewerRepository _viewerRepository;
public SteamSessionAuthenticationHandler(IOptionsMonitor<SteamAuthenticationHandlerOptions> options, ILoggerFactory logger, UrlEncoder encoder, SteamSessionService sessionService, IViewerRepository viewerRepository) : base(options, logger, encoder)
{
_sessionService = sessionService;
_viewerRepository = viewerRepository;
}
protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
{
string path = Request.Path;
// WebSocket upgrades carry no body — Request.Body.Seek throws NotSupportedException
// on Kestrel's HttpRequestStream. The battle node has its own per-connection auth
// (encrypted viewerId header validated against the matched battle id), so the
// Steam handler has nothing to do here. Returning NoResult lets the request proceed
// unauthenticated to the WS endpoint.
// Header-based detection: Context.WebSockets.IsWebSocketRequest needs UseWebSockets()
// to have already run, but UseBattleNode (which calls UseWebSockets) is registered
// AFTER UseAuthentication in Program.cs. Reading the raw Upgrade header works
// regardless of middleware order.
if (string.Equals(Request.Headers["Upgrade"].ToString(), "websocket", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
// Read the auth tuple from HttpContext.Items, populated by ShadowverseTranslationMiddleware
// off the raw decrypted msgpack dict BEFORE the action's typed DTO deserialize. This
// decouples auth from DTO shape — see AuthFields and the design spec at
// docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md. The prior
// approach re-parsed Request.Body as JSON into a BaseRequest; any action whose DTO didn't
// inherit BaseRequest silently 401'd because the msgpack→DTO→JSON pivot dropped the fields.
if (Context.Items[AuthFields.ContextKey] is not AuthFields auth)
{
Logger.LogWarning(
"Auth: no AuthFields in HttpContext.Items on {Path}. The translation middleware " +
"either didn't run (non-Unity UA?) or the body wasn't a msgpack map.",
path);
return AuthenticateResult.Fail("Invalid request body.");
}
if (string.IsNullOrEmpty(auth.SteamSessionTicket))
{
Logger.LogWarning(
"Auth: request body missing steam_session_ticket on {Path} (hasViewerId={HasViewerId}, steamId={SteamId}).",
path, !string.IsNullOrEmpty(auth.ViewerId), auth.SteamId);
return AuthenticateResult.Fail("Invalid request body.");
}
// Check steam session validity
bool sessionIsValid = _sessionService.IsTicketValidForUser(auth.SteamSessionTicket, auth.SteamId);
if (!sessionIsValid)
{
Logger.LogWarning(
"Auth: Steam ticket rejected on {Path} for steamId={SteamId} (ticketLen={TicketLen}). " +
"See SteamSessionService logs above for the underlying Steam reason (BeginAuthSession failure, duplicate, etc.).",
path, auth.SteamId, auth.SteamSessionTicket.Length);
return AuthenticateResult.Fail("Invalid ticket.");
}
Viewer? viewer =
await _viewerRepository.GetViewerBySocialConnection(SocialAccountType.Steam, auth.SteamId);
if (viewer is null)
{
// Find-or-link: first authenticated request after /tool/signup. The client signed up
// anonymously and has no Steam social row yet; if the UDID resolves to a viewer, attach
// Steam to it now so subsequent requests hit the fast SteamId path. The unique index
// on SocialAccountConnection (AccountType, AccountId) — declared in OnModelCreating —
// is the second-layer dedup backstop: if two concurrent first-touches both pass the
// .Any(...) check in LinkSteamToViewer, the second SaveChanges throws cleanly instead
// of silently duplicating connections.
Guid? udid = Context.GetUdid();
if (udid is Guid u && u != Guid.Empty)
{
viewer = await _viewerRepository.GetViewerByUdid(u);
if (viewer is not null)
{
await _viewerRepository.LinkSteamToViewer(viewer.Id, auth.SteamId);
// Re-read with socials so transition_account_data downstream sees the new link.
viewer = await _viewerRepository.GetViewerWithSocials(viewer.Id) ?? viewer;
Logger.LogInformation(
"Auth: linked steamId={SteamId} to UDID-keyed viewer_id={ViewerId} on {Path} (first-Steam-touch).",
auth.SteamId, viewer.Id, path);
}
}
if (viewer is null)
{
Logger.LogWarning(
"Auth: no viewer linked to steamId={SteamId} on {Path}, and no UDID-keyed viewer to link to. " +
"Client must call /tool/signup before authenticated endpoints.",
auth.SteamId, path);
return AuthenticateResult.Fail("User not found.");
}
}
// Add viewer to context
Context.SetViewer(viewer);
// Build identity
ClaimsIdentity identity = new ClaimsIdentity(SteamAuthenticationConstants.SchemeName);
identity.AddClaim(new Claim(ClaimTypes.Name, viewer.DisplayName));
identity.AddClaim(new Claim(ShadowverseClaimTypes.ShortUdidClaim, viewer.ShortUdid.ToString()));
identity.AddClaim(new Claim(ShadowverseClaimTypes.ViewerIdClaim, viewer.Id.ToString()));
identity.AddClaim(new Claim(SteamAuthenticationConstants.SteamIdClaim, auth.SteamId.ToString()));
// Build and return final ticket
AuthenticationTicket ticket =
new AuthenticationTicket(new ClaimsPrincipal(identity), SteamAuthenticationConstants.SchemeName);
return AuthenticateResult.Success(ticket);
}
}