fix(all): recover after game session loss
This commit is contained in:
@@ -28,6 +28,8 @@ const (
|
||||
maxCookieHeaderLen = 8 << 10
|
||||
)
|
||||
|
||||
var errSessionRequired = errors.New("session login required")
|
||||
|
||||
type LoginService interface {
|
||||
Login(request, sessionKey []byte) ([]byte, error)
|
||||
}
|
||||
@@ -372,13 +374,16 @@ func (s *Server) dispatch(path string, request []byte) (int, []byte, error) {
|
||||
func (s *Server) authorize(cookie string) (*gameSession, error) {
|
||||
token, err := parseSessionCookie(cookie)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSessionRequired) {
|
||||
return nil, fmt.Errorf("%w: %v", transport.ErrGameSessionExpired, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
now := s.now()
|
||||
s.pruneSessions(now)
|
||||
game, ok := s.sessions[sessionTokenKey(token)]
|
||||
if !ok {
|
||||
return nil, errors.New("invalid game session cookie")
|
||||
return nil, fmt.Errorf("%w: cookie no longer names a live session", transport.ErrGameSessionExpired)
|
||||
}
|
||||
game.lastUsed = now
|
||||
return game, nil
|
||||
@@ -386,7 +391,7 @@ func (s *Server) authorize(cookie string) (*gameSession, error) {
|
||||
|
||||
func parseSessionCookie(cookie string) (string, error) {
|
||||
if cookie == "" {
|
||||
return "", errors.New("session login required")
|
||||
return "", errSessionRequired
|
||||
}
|
||||
if len(cookie) > maxCookieHeaderLen {
|
||||
return "", errors.New("game session cookie header is too large")
|
||||
@@ -405,7 +410,7 @@ func parseSessionCookie(cookie string) (string, error) {
|
||||
token = candidate
|
||||
}
|
||||
if !seen {
|
||||
return "", errors.New("session login required")
|
||||
return "", errSessionRequired
|
||||
}
|
||||
if len(token) != 50 || token[48:] != "|1" {
|
||||
return "", errors.New("invalid game session cookie")
|
||||
|
||||
@@ -297,6 +297,8 @@ func TestGameSessionExpiresAndClearsKey(t *testing.T) {
|
||||
}
|
||||
if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil {
|
||||
t.Fatal("expired game session was accepted")
|
||||
} else if !errors.Is(err, transport.ErrGameSessionExpired) {
|
||||
t.Fatalf("expired game session error=%v, want ErrGameSessionExpired", err)
|
||||
}
|
||||
if len(server.sessions) != 0 {
|
||||
t.Fatalf("expired game session remains in map: %d", len(server.sessions))
|
||||
@@ -395,8 +397,16 @@ func TestNativeLoginAndBatch(t *testing.T) {
|
||||
|
||||
func TestSessionRejectsMissingCookieAndUnknownPath(t *testing.T) {
|
||||
server, _ := NewServer(fakeLogin{}, fakeDomain{})
|
||||
if _, err := server.DispatchRaw("/EmptyInfo", nil, ""); err == nil {
|
||||
t.Fatal("authenticated endpoint accepted missing cookie")
|
||||
if _, err := server.DispatchRaw("/EmptyInfo", nil, ""); !errors.Is(err, transport.ErrGameSessionExpired) {
|
||||
t.Fatalf("missing cookie error=%v, want ErrGameSessionExpired", err)
|
||||
}
|
||||
unknown := strings.Repeat("a", 48) + "|1"
|
||||
if _, err := server.DispatchRaw("/BatchRequest", nil, "s="+unknown); !errors.Is(err, transport.ErrGameSessionExpired) {
|
||||
t.Fatalf("unknown cookie error=%v, want ErrGameSessionExpired", err)
|
||||
}
|
||||
if _, err := server.DispatchRaw("/EmptyInfo", nil, "s=malformed"); err == nil ||
|
||||
errors.Is(err, transport.ErrGameSessionExpired) {
|
||||
t.Fatalf("malformed cookie error=%v, want ordinary rejection", err)
|
||||
}
|
||||
reply := login(t, server)
|
||||
request := wire.AppendVarint(nil, 1, 99)
|
||||
|
||||
@@ -59,6 +59,13 @@ type Bootstrap struct {
|
||||
|
||||
var ErrNotImplemented = errors.New("packet not implemented")
|
||||
|
||||
// ErrGameSessionExpired tells the HTTP adapter that a syntactically valid
|
||||
// game-session cookie no longer names a live session. A dedicated status and
|
||||
// header let the client distinguish this condition from a transient network
|
||||
// outage for both ordinary and batch requests; the server cannot encode a
|
||||
// response with the per-session key after that key has been lost.
|
||||
var ErrGameSessionExpired = errors.New("game session expired")
|
||||
|
||||
func (b Bootstrap) Dispatch(path string, request []byte) (Reply, error) {
|
||||
switch path {
|
||||
case "/MaintenanceInfo":
|
||||
@@ -207,6 +214,13 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
reply, err := h.Raw.DispatchRaw(path, body, r.Header.Get("Cookie"))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGameSessionExpired) {
|
||||
h.logger().Info("game session expired", "path", path, "duration_ms", elapsedMilliseconds(started))
|
||||
w.Header().Set("X-BD2-Session-Expired", "1")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(w, "game session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h.logger().Warn("session packet rejected", "path", path, "duration_ms", elapsedMilliseconds(started), "error", err)
|
||||
http.Error(w, "session packet rejected", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package transport
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -68,6 +69,38 @@ func (cookieRawDispatcher) DispatchRaw(string, []byte, string) (RawReply, error)
|
||||
return RawReply{Body: []byte(`{}`), Cookie: "0123456789abcdef0123456789abcdef0123456789abcdef|1"}, nil
|
||||
}
|
||||
|
||||
type failedRawDispatcher struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (d failedRawDispatcher) DispatchRaw(string, []byte, string) (RawReply, error) {
|
||||
return RawReply{}, d.err
|
||||
}
|
||||
|
||||
func TestExpiredGameSessionUsesDedicatedHTTPMarker(t *testing.T) {
|
||||
h := HTTP{Raw: failedRawDispatcher{err: ErrGameSessionExpired}}.Handler()
|
||||
request := httptest.NewRequest(http.MethodPut, "/game/BatchRequest", strings.NewReader("encrypted"))
|
||||
request.Header.Set("Cookie", "s=0123456789abcdef0123456789abcdef0123456789abcdef|1")
|
||||
response := httptest.NewRecorder()
|
||||
h.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusUnauthorized || response.Header().Get("X-BD2-Session-Expired") != "1" ||
|
||||
response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("status=%d marker=%q cache=%q body=%q", response.Code,
|
||||
response.Header().Get("X-BD2-Session-Expired"), response.Header().Get("Cache-Control"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainFailureDoesNotUseExpiredSessionMarker(t *testing.T) {
|
||||
h := HTTP{Raw: failedRawDispatcher{err: errors.New("mail seed is invalid")}}.Handler()
|
||||
response := httptest.NewRecorder()
|
||||
h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
|
||||
if response.Code != http.StatusBadRequest || response.Header().Get("X-BD2-Session-Expired") != "" {
|
||||
t.Fatalf("status=%d marker=%q body=%q", response.Code,
|
||||
response.Header().Get("X-BD2-Session-Expired"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthGameSessionCookieIsHostOnlySecureAndGameScoped(t *testing.T) {
|
||||
h := HTTP{
|
||||
Raw: cookieRawDispatcher{},
|
||||
|
||||
Reference in New Issue
Block a user