Files
SVSimServer/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs
gamer147 ccc9b41473 fix(battle-node): header-based WS detection in auth; split unknown-bid vs mismatch logs
Previous fix used Context.WebSockets.IsWebSocketRequest, but that
requires UseWebSockets() to have already run — and UseBattleNode
(which calls UseWebSockets) is registered AFTER UseAuthentication
in Program.cs, so the WS feature isn't installed when auth runs.
Switch to reading the raw Upgrade header, which works regardless
of middleware order.

Also split the WS handler's "Unknown battle/viewer pair" warning
into two distinct cases so we can tell unknown-BattleId from
viewer-id-mismatch (which lets us see whether the bridge stored
the right viewer or the client is encrypting a different id).

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

160 lines
7.8 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 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();
}
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);
}
}