From c68ce363ce53d5d772a22f0777e965fb539dd12c Mon Sep 17 00:00:00 2001 From: cetteup Date: Wed, 13 Nov 2024 19:12:36 +0100 Subject: [PATCH] feat: Add player verification endpoint --- cmd/playerpath/internal/handler/foward.go | 120 +++++++++------------- cmd/playerpath/internal/handler/verify.go | 100 ++++++++++++++++++ cmd/playerpath/main.go | 2 + 3 files changed, 148 insertions(+), 74 deletions(-) create mode 100644 cmd/playerpath/internal/handler/verify.go diff --git a/cmd/playerpath/internal/handler/foward.go b/cmd/playerpath/internal/handler/foward.go index dd46007..8db4871 100644 --- a/cmd/playerpath/internal/handler/foward.go +++ b/cmd/playerpath/internal/handler/foward.go @@ -26,83 +26,33 @@ func (h *Handler) HandleDynamicForward(c echo.Context) error { p := struct { PID int `query:"pid"` }{} - if err2 := c.Bind(&p); err2 != nil { + if err := c.Bind(&p); err != nil { return c.String(http.StatusOK, asp.NewSyntaxErrorResponse().Serialize()) } - pv, err2 := h.determineProvider(c.Request().Context(), p.PID) - if err2 != nil { - return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err2) - } - - log.Debug(). - Stringer(trace.LogProvider, pv). - Str("URI", c.Request().RequestURI). - Msg("Forwarding request") - - res, err2 := h.forwardRequest(c.Request().Context(), pv, c.Request(), c.RealIP()) - if err2 != nil { - return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err2) - } - - // Copy all upstream header to ensure response can be handled correctly downstream - for key, values := range res.Header { - for _, value := range values { - c.Response().Header().Add(key, value) - } + pv, err := h.determineProvider(c.Request().Context(), p.PID) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err) } - return c.String(res.StatusCode, string(res.Body)) + return h.handleForward(c, pv) } func (h *Handler) HandleStaticForward(c echo.Context) error { - res, err2 := h.forwardRequest(c.Request().Context(), h.provider, c.Request(), c.RealIP()) - if err2 != nil { - return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err2) - } - - // Copy all upstream header to ensure response can be handled correctly downstream - for key, values := range res.Header { - for _, value := range values { - c.Response().Header().Add(key, value) - } - } - - return c.String(res.StatusCode, string(res.Body)) + return h.handleForward(c, h.provider) } -func (h *Handler) determineProvider(ctx context.Context, pid int) (provider.Provider, error) { - p, err := h.repository.FindByPID(ctx, pid) - if err != nil { - if errors.Is(err, player.ErrPlayerNotFound) { - log.Warn(). - Int(trace.LogPlayerPID, pid). - Msg("Player not found, falling back to default provider") - return h.provider, nil - } - if errors.Is(err, player.ErrMultiplePlayersFound) { - log.Warn(). - Int(trace.LogPlayerPID, pid). - Msg("Found multiple players, falling back to default provider") - return h.provider, nil - } - return 0, err - } - - return p.Provider, nil -} - -func (h *Handler) forwardRequest(ctx context.Context, pv provider.Provider, incoming *http.Request, realIP string) (*UpstreamResponse, error) { +func (h *Handler) handleForward(c echo.Context, pv provider.Provider) error { u, err := url.Parse(pv.BaseURL()) if err != nil { - return nil, err + return err } - u = u.JoinPath(incoming.URL.Path) - u.RawQuery = incoming.URL.RawQuery + u = u.JoinPath(c.Request().URL.Path) + u.RawQuery = c.Request().URL.RawQuery - req, err := http.NewRequestWithContext(ctx, incoming.Method, u.String(), incoming.Body) + req, err := http.NewRequestWithContext(c.Request().Context(), c.Request().Method, u.String(), c.Request().Body) if err != nil { - return nil, err + return err } // Use GameSpy host value only if provider requires it @@ -119,29 +69,51 @@ func (h *Handler) forwardRequest(ctx context.Context, pv provider.Provider, inco } // Copy downstream user agent to ensure compatibility - req.Header.Set("User-Agent", incoming.Header.Get("User-Agent")) - req.Header.Set("X-Forwarded-Proto", incoming.Proto) - req.Header.Set("X-Forwarded-For", realIP) - req.Header.Set("X-Real-IP", realIP) + req.Header.Set("User-Agent", c.Request().Header.Get("User-Agent")) + req.Header.Set("X-Forwarded-Proto", c.Request().Proto) + req.Header.Set("X-Forwarded-For", c.RealIP()) + req.Header.Set("X-Real-IP", c.RealIP()) + + log.Debug(). + Stringer(trace.LogProvider, pv). + Str("URI", c.Request().RequestURI). + Msg("Forwarding request") res, err := h.client.Do(req) if err != nil { - return nil, err + return err } bytes, err := io.ReadAll(res.Body) if err != nil { - return nil, err + return err } err = res.Body.Close() if err != nil { - return nil, err + return err } - return &UpstreamResponse{ - StatusCode: res.StatusCode, - Header: res.Header, - Body: bytes, - }, nil + return c.String(res.StatusCode, string(bytes)) +} + +func (h *Handler) determineProvider(ctx context.Context, pid int) (provider.Provider, error) { + p, err := h.repository.FindByPID(ctx, pid) + if err != nil { + if errors.Is(err, player.ErrPlayerNotFound) { + log.Warn(). + Int(trace.LogPlayerPID, pid). + Msg("Player not found, falling back to default provider") + return h.provider, nil + } + if errors.Is(err, player.ErrMultiplePlayersFound) { + log.Warn(). + Int(trace.LogPlayerPID, pid). + Msg("Found multiple players, falling back to default provider") + return h.provider, nil + } + return 0, err + } + + return p.Provider, nil } diff --git a/cmd/playerpath/internal/handler/verify.go b/cmd/playerpath/internal/handler/verify.go new file mode 100644 index 0000000..9be5358 --- /dev/null +++ b/cmd/playerpath/internal/handler/verify.go @@ -0,0 +1,100 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + + "github.com/cetteup/playerpath/cmd/playerpath/internal/asp" + "github.com/cetteup/playerpath/internal/domain/player" + "github.com/cetteup/playerpath/internal/domain/provider" + "github.com/cetteup/playerpath/internal/trace" +) + +const ( + dummyPID = 0 +) + +func (h *Handler) HandleGetVerifyPlayer(c echo.Context) error { + params := struct { + PID int `query:"pid"` + Nick string `query:"SoldierNick"` + }{} + if err := c.Bind(¶ms); err != nil { + return c.String(http.StatusOK, asp.NewSyntaxErrorResponse().Serialize()) + } + + p, err := h.repository.FindByPID(c.Request().Context(), params.PID) + if err != nil { + if errors.Is(err, player.ErrPlayerNotFound) { + log.Warn(). + Int(trace.LogPlayerPID, params.PID). + Msg("Player not found, treating as unverified") + // Treating not found as unverified here to ensure you cannot bypass verification simply by using + // an unknown PID when using a non-verifying default provider such as BF2Hub + return c.String(http.StatusOK, buildResponse( + addInvalidPrefix(params.Nick), + params.Nick, + dummyPID, + params.PID, + ).Serialize()) + } + if errors.Is(err, player.ErrMultiplePlayersFound) { + log.Warn(). + Int(trace.LogPlayerPID, params.PID). + Msg("Found multiple players, using default provider to verify player") + // Using the default provider here isn't great either, but there is no clean solution to this conflict + // If we treat the conflict as a verification failure, neither of the conflicting PID players will pass + // By leaving the verification up to the default provider (which should be the provider used by the server), + // the provider can (potentially) resolve the conflict based on the `auth` parameter + return h.handleForward(c, h.provider) + } + return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(fmt.Errorf("failed to find player: %w", err)) + } + + switch p.Provider { + case provider.ProviderBF2Hub, provider.ProviderB2BF2: + // Neither BF2Hub nor B2BF2 currently offer a (compatible) VerifyPlayer.aspx endpoint + // All we can do is validate that an account with the given pid and name exists + return c.String(http.StatusOK, buildResponse(p.Nick, params.Nick, p.PID, params.PID).Serialize()) + default: + return h.handleForward(c, p.Provider) + } +} + +// buildResponse Signature analog to default onPlayerNameValidated handler +func buildResponse(realNick, oldNick string, realPID, oldPID int) *asp.Response { + resp := asp.NewOKResponse(). + WriteHeader("pid", "nick", "spid", "asof"). + WriteData(strconv.Itoa(realPID), realNick, strconv.Itoa(oldPID), asp.Timestamp()). + WriteHeader("result") + + if realNick == oldNick && realPID == oldPID { + resp.WriteData("Ok") + } else if realNick != oldNick && realPID != oldPID { + // We obviously cannot validate the auth param, but neither value matching would indicate + // that the player was not found and this is the closest to "completely invalid" there is + // (no player can be logged into a profile that does not exist) + resp.WriteData("InvalidAuthProfileID") + } else if realNick != oldNick { + resp.WriteData("InvalidReportedNick") + } else { + // Currently unused as realNick differs from oldNick for any non-ok response + // Primarily here for completeness-sake + resp.WriteData("InvalidReportedProfileID") + } + + return resp +} + +func addInvalidPrefix(nick string) string { + // `[prefix] nick` usually get cut off after 23 characters in the game's client-server protocols + // While the limit appears to not be applied to values returned by the validation, + // it's probably best to follow that convention/limit + prefixed := "INVALID " + nick + return prefixed[:min(len(prefixed), 23)] +} diff --git a/cmd/playerpath/main.go b/cmd/playerpath/main.go index 408cd0c..dff6d8b 100644 --- a/cmd/playerpath/main.go +++ b/cmd/playerpath/main.go @@ -106,6 +106,8 @@ func main() { asp.GET("/getawardsinfo.aspx", h.HandleDynamicForward) asp.GET("/getunlocksinfo.aspx", h.HandleDynamicForward) asp.GET("/getrankinfo.aspx", h.HandleDynamicForward) + // Requests with special/split handling + asp.GET("/VerifyPlayer.aspx", h.HandleGetVerifyPlayer) // Fallback forward to default provider asp.Any("/*.aspx", h.HandleStaticForward)