sessionProfile tagged properties with omitempty, so an upstream answer of "properties": [] (or null, or no key at all) reached Velocity with no properties key. A Yggdrasil root may legitimately answer that way for a player without a skin. Velocity 3.5.1's GameProfile deserializer passes the missing key on as null and ImmutableList.copyOf throws, so that player hangs at login with nothing logged, even though the same answer sent straight to Velocity is accepted. Mojang always sends textures, which is why the premium path and the hardware runs never hit it. Drop omitempty and replace a nil slice with an empty one before the response is written. Removing omitempty alone is not enough: a nil slice marshals as null, which Velocity rejects the same way. The new subtest feeds the relay [], null and a missing key and expects "properties":[] every time. The previous handler fails all three.
316 lines
13 KiB
Go
316 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"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,
|
|
// A redirect is not a hasJoined answer. Following one would let a configured root point
|
|
// this host at any URL it can reach — this listener included, where each hop re-runs the
|
|
// whole source scan inside the same login's timeout. The 3xx is returned as-is and the
|
|
// resolver skips that source like any other non-200.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
|
|
// 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. Prefix is the in-game rename applied to a
|
|
// player of this source who is holding a Mojang player's name (see prefixedName); it is
|
|
// unused on the identity source, whose players are never renamed.
|
|
type AuthSource struct {
|
|
Tag string
|
|
Prefix 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, and
|
|
// it is always emitted as an array: Velocity's GameProfile parser throws on a missing or
|
|
// null properties key, while a Yggdrasil root may legitimately send [] or omit it for a
|
|
// player with no skin.
|
|
type sessionProfile struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Properties []json.RawMessage `json:"properties"`
|
|
}
|
|
|
|
// HasJoinedHandler returns an http.Handler serving only the Felis-nano hasJoined
|
|
// multiplexer route (GET /session/minecraft/hasJoined), for a standalone host that
|
|
// federates logins without standing up the full felis-api. sources is the priority list
|
|
// (put the Mojang identity source first for 正版优先); repo backs the reclaim blacklist
|
|
// gate — a stub that never bars is fine for a host without the reclaim DB. The full
|
|
// felis-api mounts the same handler through its internal-face route table instead.
|
|
func HasJoinedHandler(sources []AuthSource, repo Repo) http.Handler {
|
|
a := &API{AuthSources: sources, Repo: repo}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /session/minecraft/hasJoined", a.handleHasJoined)
|
|
return mux
|
|
}
|
|
|
|
// 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 {
|
|
// A third-party source is untrusted input, its name included: nothing stops a
|
|
// hostile or sloppy root from answering with "§4admin", an empty string, or 200
|
|
// characters, all of which this handler would otherwise relay straight into the
|
|
// proxy's player list.
|
|
if !mcUsernameRe.MatchString(prof.Name) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
|
|
|
|
// Give a Mojang player's name back to the Mojang player. The UUID rewrite above
|
|
// already keeps the two apart as identities, but the proxy's player registry is
|
|
// keyed on the NAME (Velocity: "You are already connected to this proxy!"), so
|
|
// without this they cannot even be online at the same time. Renaming only on an
|
|
// actual collision leaves the ordinary third-party player's name untouched.
|
|
if isPremiumName(r.Context(), prof.Name) {
|
|
prof.Name = prefixedName(src.Prefix, prof.Name)
|
|
}
|
|
}
|
|
|
|
// 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[:])
|
|
if prof.Properties == nil {
|
|
prof.Properties = []json.RawMessage{} // a nil slice would marshal as null
|
|
}
|
|
writeJSON(w, http.StatusOK, prof)
|
|
}
|
|
|
|
// mcUsernameRe is Minecraft's username charset — the trust boundary on a third-party
|
|
// source's self-asserted profile name.
|
|
var mcUsernameRe = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
|
|
|
|
// mcUsernameMax is the protocol's username length ceiling, which prefixedName must respect.
|
|
const mcUsernameMax = 16
|
|
|
|
// prefixedName is the squatter rename: ("LS", "steve") → "LS_steve". The base name is
|
|
// TRUNCATED to fit rather than the rename being skipped when it would not fit — skipping is
|
|
// what would silently hand a 14-character premium name back to the squatter.
|
|
//
|
|
// ponytail: two players of one source whose names agree on their first mcUsernameMax-len(prefix)-1
|
|
// characters truncate onto the same in-game name, as does a prefixed name that happens to be
|
|
// a premium name itself. Both cost an "already connected" bounce, not an identity: the UUID
|
|
// rewrite is what keeps players apart, and it does not depend on the name at all. Add a
|
|
// disambiguating suffix only if real players actually collide.
|
|
func prefixedName(prefix, name string) string {
|
|
p := prefix + "_"
|
|
if keep := mcUsernameMax - len(p); len(name) > keep {
|
|
name = name[:keep]
|
|
}
|
|
return p + name
|
|
}
|
|
|
|
// mojangProfileAPI answers the one question that decides a rename: is this username
|
|
// registered to a Mojang account? A var, not a const, so a test can point it at a stub
|
|
// instead of the real Mojang.
|
|
var mojangProfileAPI = "https://api.mojang.com/users/profiles/minecraft/"
|
|
|
|
// profileHTTPClient is deliberately more impatient than authHTTPClient: the name lookup is a
|
|
// SECOND Mojang round-trip on a third-party login (the identity leg already spent one), and
|
|
// api.mojang.com is exactly what is unreliable from the networks these servers sit on. A
|
|
// slow answer falls back to the cache instead of holding the login open.
|
|
var profileHTTPClient = &http.Client{Timeout: 2 * time.Second}
|
|
|
|
// A name's premium status changes on human timescales, not per login, so it is cached — but
|
|
// asymmetrically, because the two directions have very different costs. "Taken" is nearly
|
|
// permanent (Mojang does not recycle names), while "free" can stop being true the moment
|
|
// someone buys that name, and a stale "free" is the dangerous one: it leaves a squatter
|
|
// holding a name its real owner has just bought. So a "free" answer is trusted for minutes
|
|
// and a "taken" answer for a day.
|
|
const (
|
|
premiumTakenTTL = 24 * time.Hour
|
|
premiumFreeTTL = 10 * time.Minute
|
|
premiumCacheMax = 4096
|
|
)
|
|
|
|
type premiumEntry struct {
|
|
taken bool
|
|
at time.Time
|
|
}
|
|
|
|
var premiumNames = struct {
|
|
sync.Mutex
|
|
m map[string]premiumEntry
|
|
}{m: make(map[string]premiumEntry)}
|
|
|
|
// isPremiumName reports whether username belongs to a real Mojang account — which is what
|
|
// makes a third-party player holding it a squatter. On a lookup failure it prefers a stale
|
|
// cached answer, and with nothing cached it fails CLOSED (assume premium → rename the
|
|
// third-party player): a Mojang outage must not let a squatter keep a name the real owner is
|
|
// about to log in with. Being wrong that way costs a cosmetic prefix; being wrong the other
|
|
// way bounces the name's actual owner off the proxy.
|
|
func isPremiumName(ctx context.Context, username string) bool {
|
|
key := strings.ToLower(username)
|
|
|
|
premiumNames.Lock()
|
|
cached, hit := premiumNames.m[key]
|
|
premiumNames.Unlock()
|
|
if hit && time.Since(cached.at) < premiumTTL(cached.taken) {
|
|
return cached.taken
|
|
}
|
|
|
|
taken, err := lookupPremiumName(ctx, username)
|
|
if err != nil {
|
|
if hit {
|
|
return cached.taken
|
|
}
|
|
return true
|
|
}
|
|
|
|
premiumNames.Lock()
|
|
// ponytail: bounded by dropping the whole map rather than evicting LRU — entries are
|
|
// only minted by players who actually authenticated somewhere, so this is a backstop
|
|
// against an unbounded map, not a cache policy worth tuning.
|
|
if len(premiumNames.m) >= premiumCacheMax {
|
|
clear(premiumNames.m)
|
|
}
|
|
premiumNames.m[key] = premiumEntry{taken: taken, at: time.Now()}
|
|
premiumNames.Unlock()
|
|
return taken
|
|
}
|
|
|
|
func premiumTTL(taken bool) time.Duration {
|
|
if taken {
|
|
return premiumTakenTTL
|
|
}
|
|
return premiumFreeTTL
|
|
}
|
|
|
|
// lookupPremiumName asks Mojang whether a name is registered: 200 = it is, 404 (204 on the
|
|
// legacy endpoint) = it is free. Anything else is an ERROR, never a "no" — a 429 or a 503
|
|
// must not read as "this name is unowned"; see isPremiumName's fail-closed rule.
|
|
func lookupPremiumName(ctx context.Context, username string) (bool, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mojangProfileAPI+url.PathEscape(username), nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
resp, err := profileHTTPClient.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
return true, nil
|
|
case http.StatusNotFound, http.StatusNoContent:
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("mojang profile api: %s", resp.Status)
|
|
}
|
|
|
|
// 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{}
|
|
}
|