fix(all): recover after game session loss

This commit is contained in:
2026-10-03 00:44:32 +08:00
parent 1251ab900f
commit 30d9ef7f4e
6 changed files with 200 additions and 7 deletions
+8 -3
View File
@@ -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")
+12 -2
View File
@@ -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)
+14
View File
@@ -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
+33
View File
@@ -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{},