feat(nano): federating hasJoined multiplexer with per-source UUID namespacing
This commit is contained in:
5 files changed
+362
-1
No files matched your search
@@ -439,6 +439,41 @@ paths:
|
|||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
|
/session/minecraft/hasJoined:
|
||||||
|
get:
|
||||||
|
tags: [nano]
|
||||||
|
operationId: hasJoined
|
||||||
|
summary: Multi-source session verifier (Felis-nano hasJoined multiplexer).
|
||||||
|
description: >-
|
||||||
|
Velocity's authlib is pointed here via -Dmojang.sessionserver or a thin login
|
||||||
|
hook. Unauthenticated — the vanilla sessionserver protocol carries no token. The
|
||||||
|
query is fanned out to the configured Yggdrasil roots in priority order (the
|
||||||
|
Mojang identity source first); the first source to validate the serverId hash
|
||||||
|
wins. A non-identity source's self-asserted UUID is rewritten into a per-source
|
||||||
|
namespace (UUIDv3) before return, so it can never land in Mojang's UUID space.
|
||||||
|
A rejected or barred login is 204, which authlib maps to a verify failure.
|
||||||
|
x-felis-face: [internal]
|
||||||
|
x-felis-tier: public
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- { name: username, in: query, required: true, schema: { type: string } }
|
||||||
|
- { name: serverId, in: query, required: true, schema: { type: string } }
|
||||||
|
- { name: ip, in: query, required: false, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: A source validated the session; the canonical game profile.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, name]
|
||||||
|
properties:
|
||||||
|
id: { type: string, description: Canonical UUID, undashed 32-hex. }
|
||||||
|
name: { type: string }
|
||||||
|
properties: { type: array, items: { type: object } }
|
||||||
|
'204':
|
||||||
|
description: No source validated the session, or the resolved UUID is barred.
|
||||||
|
|
||||||
# -------------------------------------------------- internal: servers ------
|
# -------------------------------------------------- internal: servers ------
|
||||||
/api/v1/servers:
|
/api/v1/servers:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ require (
|
|||||||
github.com/descope/virtualwebauthn v1.0.5
|
github.com/descope/virtualwebauthn v1.0.5
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.7.1
|
github.com/jackc/pgx/v5 v5.7.1
|
||||||
github.com/prometheus/client_golang v1.19.1
|
github.com/prometheus/client_golang v1.19.1
|
||||||
github.com/prometheus/client_model v0.6.1
|
github.com/prometheus/client_model v0.6.1
|
||||||
@@ -55,7 +56,6 @@ require (
|
|||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/go-tpm v0.9.8 // indirect
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
github.com/google/gofuzz v1.2.0 // indirect
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/imdario/mergo v0.3.6 // indirect
|
github.com/imdario/mergo v0.3.6 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ type API struct {
|
|||||||
// positive value. Enforced via streamGate in the two relay handlers.
|
// positive value. Enforced via streamGate in the two relay handlers.
|
||||||
MaxStreamsPerPrincipal int
|
MaxStreamsPerPrincipal int
|
||||||
|
|
||||||
|
// AuthSources is the Felis-nano multi-source hasJoined multiplexer's upstream
|
||||||
|
// Yggdrasil list, in priority order (config order; the Mojang Identity source
|
||||||
|
// first for 正版优先). Nil — the default — makes the session verifier reject every
|
||||||
|
// login (204), so the endpoint ships inert until cmd/felis wires configured
|
||||||
|
// sources. Consumed by handleHasJoined (handlers_hasjoined.go).
|
||||||
|
AuthSources []AuthSource
|
||||||
|
|
||||||
// Now is the clock, injectable for tests. Defaults to time.Now.
|
// Now is the clock, injectable for tests. Defaults to time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
|
||||||
@@ -260,6 +267,13 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
|||||||
// Mojang player (same name, different UUID) always passes.
|
// Mojang player (same name, different UUID) always passes.
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
|
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
|
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
|
||||||
|
// Felis-nano multi-source session verifier (spec §B3 player game-login).
|
||||||
|
// Velocity's authlib is pointed here (-Dmojang.sessionserver or a thin login
|
||||||
|
// hook); it speaks the vanilla sessionserver protocol and carries no token, so
|
||||||
|
// this is Public. It fans hasJoined out to the configured Yggdrasil roots
|
||||||
|
// (Mojang-first) and rewrites third-party UUIDs into a per-source namespace
|
||||||
|
// before returning the canonical profile (handlers_hasjoined.go).
|
||||||
|
{Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined},
|
||||||
// Op-login (passwordless console login): an in-game op requests a login that
|
// Op-login (passwordless console login): an in-game op requests a login that
|
||||||
// the web owner/admin approves, then redeems for a session. Internal face
|
// the web owner/admin approves, then redeems for a session. Internal face
|
||||||
// carries the pending queue and the approve action (service-token auth, no
|
// carries the pending queue and the approve action (service-token auth, no
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Felis-nano: the multi-source hasJoined multiplexer (spec §B3 player game-login).
|
||||||
|
//
|
||||||
|
// Velocity's session verifier (authlib) is pointed here — via -Dmojang.sessionserver
|
||||||
|
// on Felis-managed proxies, or a thin login-pipeline hook on third-party servers. On
|
||||||
|
// login Velocity computes the serverId hash and GETs hasJoined; this endpoint fans that
|
||||||
|
// query out to the configured Yggdrasil roots in priority order (Mojang first, 正版优先)
|
||||||
|
// and returns the first source that validates. Each upstream Yggdrasil runs its own
|
||||||
|
// serverId-hash check — the multiplexer only relays, it computes no hashes.
|
||||||
|
//
|
||||||
|
// The one non-negotiable transform: a non-identity (third-party) source's UUID is
|
||||||
|
// self-asserted, so its profile is rewritten into a per-source namespace
|
||||||
|
// (canonical = UUIDv3(felisAuthNS, tag+":"+nativeID)) BEFORE it leaves the resolver.
|
||||||
|
// Mojang stays identity. This makes the reclaim invariant — "the genuine Mojang player
|
||||||
|
// has a DIFFERENT UUID from any squatter" — true by construction, not assumed: MD5
|
||||||
|
// preimage resistance means no third-party source can mint a Mojang-space UUID, and the
|
||||||
|
// per-tag namespace means two sources cannot collide onto one identity. Every downstream
|
||||||
|
// key (account_links, username_blacklist, owner checks) then sees one canonical UUID.
|
||||||
|
|
||||||
|
// felisAuthNS is the fixed UUIDv3 namespace every third-party profile is rewritten
|
||||||
|
// under (see the rewrite rationale above). Derived from the project name, not a magic
|
||||||
|
// literal, so its origin is self-documenting; the exact value only has to be stable.
|
||||||
|
var felisAuthNS = uuid.NewSHA1(uuid.NameSpaceURL, []byte("nano.felis.lolicon.best/auth-source"))
|
||||||
|
|
||||||
|
// authHTTPClient calls the upstream Yggdrasil roots. The timeout bounds one login
|
||||||
|
// against a hung source; the resolver moves on to the next source on any failure.
|
||||||
|
// ponytail: one shared client, sequential priority scan — a third-party login costs one
|
||||||
|
// wasted Mojang round-trip; add parallel fan-out only if login latency bites.
|
||||||
|
var authHTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
|
// AuthSource is one upstream Yggdrasil root in the multiplexer's priority list (config
|
||||||
|
// order = priority). URL is the full hasJoined endpoint the query string is appended to.
|
||||||
|
// Identity marks the authoritative source (Mojang) whose UUIDs are trusted as-is; every
|
||||||
|
// other source is rewritten into felisAuthNS.
|
||||||
|
type AuthSource struct {
|
||||||
|
Tag string
|
||||||
|
URL string
|
||||||
|
Identity bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionProfile is the Mojang hasJoined contract. properties is relayed verbatim
|
||||||
|
// (json.RawMessage) so a source's signed textures survive the multiplexer untouched.
|
||||||
|
type sessionProfile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Properties []json.RawMessage `json:"properties,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHasJoined is the multi-source session verifier (Felis-nano). It is a Public
|
||||||
|
// internal-face route: authlib speaks the vanilla sessionserver protocol and sends no
|
||||||
|
// service token. A rejected login is 204 No Content — exactly what Mojang returns for an
|
||||||
|
// invalid session, which authlib maps to "failed to verify username".
|
||||||
|
func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
username, serverID := q.Get("username"), q.Get("serverId")
|
||||||
|
if username == "" || serverID == "" {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prof, src := a.resolveHasJoined(r.Context(), username, serverID, q.Get("ip"))
|
||||||
|
if prof == nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize identity. A trusted (Mojang) source keeps its UUID; a self-asserted
|
||||||
|
// source is rewritten into felisAuthNS so it can never land in Mojang's UUID space
|
||||||
|
// nor onto another source's. An unparseable identity UUID is not trustworthy → reject.
|
||||||
|
var canonical uuid.UUID
|
||||||
|
if src.Identity {
|
||||||
|
id, err := uuid.Parse(prof.ID)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canonical = id
|
||||||
|
} else {
|
||||||
|
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bar gate at the single chokepoint every login crosses, so a reclaimed squatter
|
||||||
|
// stays out even on a consumer with no limbo plugin. Keyed on the dashed canonical
|
||||||
|
// UUID — the same form Repo.ReclaimUsername stores.
|
||||||
|
barred, err := a.Repo.IsUsernameBlacklisted(r.Context(), canonical.String())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if barred {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit the canonical UUID undashed — the 32-hex form authlib's GameProfile expects.
|
||||||
|
prof.ID = hex.EncodeToString(canonical[:])
|
||||||
|
writeJSON(w, http.StatusOK, prof)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHasJoined queries each configured source in priority order and returns the
|
||||||
|
// first that validates the session (200 with a profile). A source that is down, answers
|
||||||
|
// non-200 (204 = "not my player"), or returns garbage is skipped.
|
||||||
|
func (a *API) resolveHasJoined(ctx context.Context, username, serverID, ip string) (*sessionProfile, AuthSource) {
|
||||||
|
for _, src := range a.AuthSources {
|
||||||
|
u := src.URL + "?username=" + url.QueryEscape(username) + "&serverId=" + url.QueryEscape(serverID)
|
||||||
|
if ip != "" {
|
||||||
|
u += "&ip=" + url.QueryEscape(ip)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, err := authHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var prof sessionProfile
|
||||||
|
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&prof)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil || prof.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &prof, src
|
||||||
|
}
|
||||||
|
return nil, AuthSource{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeYgg stands in for one upstream Yggdrasil root. It answers hasJoined with the
|
||||||
|
// given profile, or 204 when id == "" ("not my player") or a query field is missing —
|
||||||
|
// the same contract Mojang's real sessionserver honors.
|
||||||
|
func fakeYgg(t *testing.T, id, name string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if id == "" || q.Get("username") == "" || q.Get("serverId") == "" {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": id, "name": name,
|
||||||
|
"properties": []map[string]string{{"name": "textures", "value": "SKIN", "signature": "SIG"}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHasJoined(h http.Handler, username, serverID string) *httptest.ResponseRecorder {
|
||||||
|
return do(h, "GET", "/session/minecraft/hasJoined?username="+username+"&serverId="+serverID, "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileOf(t *testing.T, w *httptest.ResponseRecorder) sessionProfile {
|
||||||
|
t.Helper()
|
||||||
|
var p sessionProfile
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &p); err != nil {
|
||||||
|
t.Fatalf("profile body not JSON: %v (%q)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// undashed is the 32-hex form the resolver must emit (authlib's GameProfile format).
|
||||||
|
func undashed(u uuid.UUID) string { return hex.EncodeToString(u[:]) }
|
||||||
|
|
||||||
|
const notchMojangID = "069a79f444e94726a5befca90e38aaf5" // a real Mojang-space UUID, undashed
|
||||||
|
|
||||||
|
func TestHasJoined(t *testing.T) {
|
||||||
|
// A trusted (Mojang) source passes its UUID through byte-for-byte.
|
||||||
|
t.Run("mojang identity passthrough", func(t *testing.T) {
|
||||||
|
mojang := fakeYgg(t, notchMojangID, "Notch")
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
api.AuthSources = []AuthSource{{Tag: "mojang", URL: mojang.URL, Identity: true}}
|
||||||
|
|
||||||
|
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200 (%q)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
p := profileOf(t, w)
|
||||||
|
if p.ID != notchMojangID {
|
||||||
|
t.Fatalf("mojang id = %q, want unchanged %q", p.ID, notchMojangID)
|
||||||
|
}
|
||||||
|
if len(p.Properties) != 1 {
|
||||||
|
t.Fatalf("properties not relayed: %v", p.Properties)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// THE security invariant: a self-asserted source claiming a Mojang-space UUID must
|
||||||
|
// NOT be emitted as-is — it is rewritten into the per-source namespace. Without this,
|
||||||
|
// a malicious third-party could impersonate any Mojang player with full UUID fidelity
|
||||||
|
// and the reclaim/blacklist layer (keyed on "genuine Mojang has a different UUID")
|
||||||
|
// could never catch it.
|
||||||
|
t.Run("thirdparty UUID rewritten, never emitted as-is", func(t *testing.T) {
|
||||||
|
evil := fakeYgg(t, notchMojangID, "Notch") // lies: returns real Notch's Mojang UUID
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
api.AuthSources = []AuthSource{{Tag: "littleskin", URL: evil.URL, Identity: false}}
|
||||||
|
|
||||||
|
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
got := profileOf(t, w).ID
|
||||||
|
if got == notchMojangID {
|
||||||
|
t.Fatalf("SECURITY: third-party Mojang-space UUID emitted as-is (%q) — impersonation open", got)
|
||||||
|
}
|
||||||
|
want := undashed(uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID)))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("rewrite = %q, want deterministic UUIDv3 %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mojang is priority-first: when both would validate the same name, Mojang wins.
|
||||||
|
t.Run("mojang priority wins over thirdparty", func(t *testing.T) {
|
||||||
|
mojang := fakeYgg(t, notchMojangID, "Notch")
|
||||||
|
third := fakeYgg(t, "aaaaaaaaaaaa4aaaaaaaaaaaaaaaaaaa", "Notch")
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
api.AuthSources = []AuthSource{
|
||||||
|
{Tag: "mojang", URL: mojang.URL, Identity: true},
|
||||||
|
{Tag: "littleskin", URL: third.URL, Identity: false},
|
||||||
|
}
|
||||||
|
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
|
||||||
|
if p := profileOf(t, w); p.ID != notchMojangID {
|
||||||
|
t.Fatalf("id = %q, want mojang %q (priority)", p.ID, notchMojangID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mojang doesn't know the player (204) → fall through to the third-party source,
|
||||||
|
// whose profile is returned rewritten.
|
||||||
|
t.Run("fallthrough to thirdparty when mojang 204s", func(t *testing.T) {
|
||||||
|
mojang := fakeYgg(t, "", "") // 204: not my player
|
||||||
|
third := fakeYgg(t, notchMojangID, "Notch")
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
api.AuthSources = []AuthSource{
|
||||||
|
{Tag: "mojang", URL: mojang.URL, Identity: true},
|
||||||
|
{Tag: "littleskin", URL: third.URL, Identity: false},
|
||||||
|
}
|
||||||
|
w := getHasJoined(api.InternalHandler(), "Notch", "abc")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
want := undashed(uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID)))
|
||||||
|
if p := profileOf(t, w); p.ID != want {
|
||||||
|
t.Fatalf("id = %q, want rewritten thirdparty %q", p.ID, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// No source validates → 204 (authlib maps this to a verify failure).
|
||||||
|
t.Run("no source validates -> 204", func(t *testing.T) {
|
||||||
|
a := fakeYgg(t, "", "")
|
||||||
|
b := fakeYgg(t, "", "")
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
api.AuthSources = []AuthSource{
|
||||||
|
{Tag: "mojang", URL: a.URL, Identity: true},
|
||||||
|
{Tag: "littleskin", URL: b.URL, Identity: false},
|
||||||
|
}
|
||||||
|
if w := getHasJoined(api.InternalHandler(), "Ghost", "abc"); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("code = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The reused bar gate: a barred CANONICAL UUID is rejected at the resolver, so a
|
||||||
|
// reclaimed squatter stays out even on a consumer with no limbo plugin. Keyed on the
|
||||||
|
// dashed canonical (post-rewrite), the same form Repo.ReclaimUsername stores.
|
||||||
|
t.Run("barred canonical UUID -> 204", func(t *testing.T) {
|
||||||
|
third := fakeYgg(t, notchMojangID, "Notch")
|
||||||
|
repo := newFakeRepo()
|
||||||
|
canonical := uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID))
|
||||||
|
repo.blacklist[canonical.String()] = true // barred by a prior reclaim
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.AuthSources = []AuthSource{{Tag: "littleskin", URL: third.URL, Identity: false}}
|
||||||
|
|
||||||
|
if w := getHasJoined(api.InternalHandler(), "Notch", "abc"); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("barred login: code = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Missing query fields → 204 without touching any source.
|
||||||
|
t.Run("missing username -> 204", func(t *testing.T) {
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
api.AuthSources = []AuthSource{{Tag: "mojang", URL: "http://127.0.0.1:0", Identity: true}}
|
||||||
|
w := do(api.InternalHandler(), "GET", "/session/minecraft/hasJoined?serverId=abc", "", nil)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("code = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user