mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat: add authenticated probe evidence boundary
This commit is contained in:
@@ -19,11 +19,13 @@ import (
|
||||
const maxBodyBytes = 8 << 10
|
||||
|
||||
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
|
||||
type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error)
|
||||
|
||||
type Service struct {
|
||||
Sessions *domain.SessionStore
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
Probe ProbeProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
RankedProfiles map[string]domain.RankedProfile
|
||||
@@ -38,6 +40,7 @@ func (s *Service) Handler() http.Handler {
|
||||
mux.HandleFunc("/v1/queue/", s.queueMutation)
|
||||
mux.HandleFunc("/v1/proposals/", s.proposalMutation)
|
||||
mux.HandleFunc("/v1/profile/ranked", s.rankedProfile)
|
||||
mux.HandleFunc("/v1/probes/", s.probe)
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -232,6 +235,46 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rankedProfileResponse{Rating: profile.Value, RD: profile.RD, Volatility: profile.Volatility, RankedGames: profile.RankedGames, Tier: string(tier), Provisional: domain.RankedIsProvisional(profile), SeasonID: profile.LastSeasonID})
|
||||
}
|
||||
|
||||
type probeRequest struct {
|
||||
OpaqueLocation []byte `json:"opaque_location"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
}
|
||||
|
||||
func (s *Service) probe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
playerID, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
region := strings.TrimPrefix(r.URL.Path, "/v1/probes/")
|
||||
if (region != "EU" && region != "NA") || strings.Contains(region, "/") {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
if s.Probe == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "probe_unavailable")
|
||||
return
|
||||
}
|
||||
var input probeRequest
|
||||
if !decodeBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
receivedAt := s.now()
|
||||
evidence, expectedNonce, err := s.Probe(playerID, region, input.OpaqueLocation, input.Nonce, receivedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, "probe_unavailable")
|
||||
return
|
||||
}
|
||||
if evidence.Region != region || domain.ValidateProbe(evidence, expectedNonce, receivedAt) != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_probe")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"region": region, "server_rtt_ms": evidence.ServerRTT.Milliseconds(), "status": "accepted"})
|
||||
}
|
||||
|
||||
func (s *Service) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if s.Sessions == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
|
||||
|
||||
@@ -236,3 +236,38 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) {
|
||||
t.Fatalf("ranked profile response = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
sessions := domain.NewSessionStore()
|
||||
session, token, err := sessions.Issue("player-a", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
service := &Service{Sessions: sessions, Now: func() time.Time { return now }, Probe: func(playerID, region string, location, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) {
|
||||
called = true
|
||||
if playerID != "player-a" || region != "EU" || string(location) != "opaque" || string(nonce) != "nonce" || !receivedAt.Equal(now) {
|
||||
t.Fatalf("probe provider arguments = %q %s %q %q %v", playerID, region, location, nonce, receivedAt)
|
||||
}
|
||||
return domain.ProbeEvidence{OpaqueLocation: location, Nonce: nonce, IssuedAt: now, Region: region, ServerRTT: 40 * time.Millisecond}, nonce, nil
|
||||
}}
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
request := `{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U="}`
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/probes/EU", strings.NewReader(request))
|
||||
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil || response.StatusCode != http.StatusAccepted || !called {
|
||||
t.Fatalf("valid probe status=%v err=%v called=%v", response.StatusCode, err, called)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
request = `{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U=","server_rtt_ms":1}`
|
||||
req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/probes/EU", strings.NewReader(request))
|
||||
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
|
||||
response, err = http.DefaultClient.Do(req)
|
||||
if err != nil || response.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("client RTT field status=%v err=%v", response.StatusCode, err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user