Files
SVSimServer/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs
gamer147 1252f7bd35 fix(battle-node): read WS credentials from headers; skip Steam auth on WS upgrades
Two issues caught in the real-client smoke:

1) BestHTTP's SocketOptions.AdditionalQueryParams puts BattleId and
   viewerId on HTTP request HEADERS for WebSocket-only transport
   (NOT on the URL query string as the in-battle/transport.md spec
   says). Real clients therefore send them as headers; our handler
   was reading from query and rejecting every connect with "Unknown
   battle/viewer pair: <bid>/<garbage>". Fix: header-first, query-
   fallback (so the integration test still works against TestServer).

2) The Steam auth handler was running on every WS upgrade and
   throwing NotSupportedException on Request.Body.Seek (Kestrel's
   HttpRequestStream doesn't support Seek, and a WS upgrade is GET
   with Content-Length: 0 anyway). It flooded logs and added no
   value — the battle node has its own per-connection credentials.
   Skip auth when IsWebSocketRequest is true.

Spec correction for in-battle/transport.md to follow.

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

156 lines
7.4 KiB
C#

using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
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.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuthenticationHandlerOptions>
{
// Must mirror the controller-side JSON options — the translation middleware rewrites the
// request body in snake_case, and we have to read it back the same way or every property
// binds to null and we NRE downstream against the Steam ticket.
private static readonly JsonSerializerOptions RequestJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
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 query/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.
if (Context.WebSockets.IsWebSocketRequest)
{
return AuthenticateResult.NoResult();
}
byte[] requestBytes;
try
{
using (var requestBytesStream = new MemoryStream())
{
// Reset request stream
Request.Body.Seek(0, SeekOrigin.Begin);
await Request.Body.CopyToAsync(requestBytesStream);
requestBytes = requestBytesStream.ToArray();
// Reset request stream
Request.Body.Seek(0, SeekOrigin.Begin);
}
}
catch (Exception e)
{
Logger.LogWarning(e, "Auth: failed to read request body on {Path}.", path);
return AuthenticateResult.Fail("Failed to read request body.");
}
// Convert bytes to json
string requestString = Encoding.UTF8.GetString(requestBytes);
BaseRequest? requestJson;
try
{
requestJson = JsonSerializer.Deserialize<BaseRequest>(requestString, RequestJsonOptions);
}
catch (JsonException ex)
{
Logger.LogWarning(ex,
"Auth: failed to JSON-parse request body on {Path} (bodyLen={BodyLen}). " +
"Translation middleware should have rewritten this to JSON — if it didn't, the request bypassed translation (non-Unity UA?).",
path, requestBytes.Length);
return AuthenticateResult.Fail("Invalid request body.");
}
if (requestJson is null || string.IsNullOrEmpty(requestJson.SteamSessionTicket))
{
Logger.LogWarning(
"Auth: request body missing steam_session_ticket on {Path} (bodyLen={BodyLen}, hasViewerId={HasViewerId}, steamId={SteamId}).",
path, requestBytes.Length,
!string.IsNullOrEmpty(requestJson?.ViewerId), requestJson?.SteamId ?? 0);
return AuthenticateResult.Fail("Invalid request body.");
}
// Check steam session validity
bool sessionIsValid = _sessionService.IsTicketValidForUser(requestJson.SteamSessionTicket, requestJson.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, requestJson.SteamId, requestJson.SteamSessionTicket.Length);
return AuthenticateResult.Fail("Invalid ticket.");
}
Viewer? viewer =
await _viewerRepository.GetViewerBySocialConnection(SocialAccountType.Steam, requestJson.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, requestJson.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).",
requestJson.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.",
requestJson.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, requestJson.SteamId.ToString()));
// Build and return final ticket
AuthenticationTicket ticket =
new AuthenticationTicket(new ClaimsPrincipal(identity), SteamAuthenticationConstants.SchemeName);
return AuthenticateResult.Success(ticket);
}
}