(1) RegisterAnonymousViewer now catches the unique-violation
race (SQLSTATE 23505 on Postgres / code 19 on SQLite) and
re-reads by UDID, returning the existing row instead of
surfacing 500 to the second concurrent /tool/signup caller.
New repo test exercises the back-to-back register path.
(2) Add unique index on SocialAccountConnection (AccountType,
AccountId). The auth handler's find-or-link path claimed
this index existed as the dedup backstop; the claim was
accurate as design intent but the schema was missing. Now
matched. Comment in handler updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
147 lines
6.9 KiB
C#
147 lines
6.9 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;
|
|
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);
|
|
}
|
|
} |