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>
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
@@ -9,21 +7,12 @@ 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)
|
||||
@@ -48,65 +37,43 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
|
||||
{
|
||||
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)
|
||||
// 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(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);
|
||||
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 (requestJson is null || string.IsNullOrEmpty(requestJson.SteamSessionTicket))
|
||||
if (string.IsNullOrEmpty(auth.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);
|
||||
"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(requestJson.SteamSessionTicket, requestJson.SteamId);
|
||||
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, requestJson.SteamId, requestJson.SteamSessionTicket.Length);
|
||||
path, auth.SteamId, auth.SteamSessionTicket.Length);
|
||||
return AuthenticateResult.Fail("Invalid ticket.");
|
||||
}
|
||||
|
||||
Viewer? viewer =
|
||||
await _viewerRepository.GetViewerBySocialConnection(SocialAccountType.Steam, requestJson.SteamId);
|
||||
await _viewerRepository.GetViewerBySocialConnection(SocialAccountType.Steam, auth.SteamId);
|
||||
|
||||
if (viewer is null)
|
||||
{
|
||||
@@ -123,12 +90,12 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
|
||||
viewer = await _viewerRepository.GetViewerByUdid(u);
|
||||
if (viewer is not null)
|
||||
{
|
||||
await _viewerRepository.LinkSteamToViewer(viewer.Id, requestJson.SteamId);
|
||||
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).",
|
||||
requestJson.SteamId, viewer.Id, path);
|
||||
auth.SteamId, viewer.Id, path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +104,7 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
|
||||
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);
|
||||
auth.SteamId, path);
|
||||
return AuthenticateResult.Fail("User not found.");
|
||||
}
|
||||
}
|
||||
@@ -150,7 +117,7 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
|
||||
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()));
|
||||
identity.AddClaim(new Claim(SteamAuthenticationConstants.SteamIdClaim, auth.SteamId.ToString()));
|
||||
|
||||
// Build and return final ticket
|
||||
AuthenticationTicket ticket =
|
||||
|
||||
Reference in New Issue
Block a user