diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0f263a6..5215562 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -643,6 +643,15 @@ paths: required: [mc_uuid] properties: mc_uuid: { type: string } + auth_source: + type: string + enum: [mojang, thirdparty] + default: mojang + description: > + Which Yggdrasil authenticated the in-game UUID (spec §10 + dual-Yggdrasil). Optional; an omitted value defaults to the + Mojang-priority source. Captured here because only the in-game + side sees the authentication; it is copied onto the link at verify. responses: '201': description: Code minted. @@ -1427,10 +1436,14 @@ paths: application/json: schema: type: object - required: [linked, mc_uuid] + required: [linked, mc_uuid, auth_source] properties: linked: { type: boolean, const: true } mc_uuid: { type: string } + auth_source: + type: string + enum: [mojang, thirdparty] + description: The source captured at mint, copied onto the durable link. '400': description: Invalid or expired code. content: diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b6fcec7..f05cf42 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -39,6 +39,9 @@ type fakeRepo struct { // account linking (spec §10) linkCodes map[string]fakeLinkCode // code -> pending binding links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid)) + // linkAuthSource mirrors account_links.auth_source (mc_uuid -> mojang|thirdparty), + // the value copied from the consumed code at verify (migration 0005). + linkAuthSource map[string]string // world backups (spec §7, §22). A nil slice lists empty. backups []fakeBackup // local-password auth (spec §B). staff is keyed by username (the login key); @@ -85,8 +88,9 @@ type fakeBackup struct { // fakeLinkCode mirrors an account_link_codes row. type fakeLinkCode struct { - mcUUID string - expiresAt time.Time + mcUUID string + authSource string + expiresAt time.Time } func newFakeRepo() *fakeRepo { @@ -98,10 +102,11 @@ func newFakeRepo() *fakeRepo { claimOK: map[string]bool{}, seeded: map[string]bool{}, aliases: map[string]string{}, linkCodes: map[string]fakeLinkCode{}, links: map[string]string{}, - staff: map[string]*StaffUser{}, - sessions: map[string]*fakeSession{}, - settings: map[string][]byte{}, - otps: map[string]*fakeEmailOTP{}, + linkAuthSource: map[string]string{}, + staff: map[string]*StaffUser{}, + sessions: map[string]*fakeSession{}, + settings: map[string][]byte{}, + otps: map[string]*fakeEmailOTP{}, } } @@ -119,27 +124,29 @@ func (f *fakeRepo) ServerByName(_ context.Context, n string) (*ServerRecord, err } func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil } func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil } -func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID string, expiresAt time.Time) error { - f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, expiresAt: expiresAt} +func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error { + f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt} return nil } // VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests // exercise the same contract the integration impl honors: strict expiry against // the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the -// code, same (user, uuid) idempotent, and the code consumed only on success. -func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, error) { +// code, same (user, uuid) idempotent, the code's auth_source copied onto the link +// (and refreshed on re-verify), and the code consumed only on success. +func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, string, error) { rec, ok := f.linkCodes[code] if !ok || !rec.expiresAt.After(now) { - return "", ErrLinkCodeInvalid + return "", "", ErrLinkCodeInvalid } if existing, ok := f.links[rec.mcUUID]; ok && existing != userID { - return "", ErrConflict // do not consume another user's pending code + return "", "", ErrConflict // do not consume another user's pending code } f.links[rec.mcUUID] = userID + f.linkAuthSource[rec.mcUUID] = rec.authSource // copy/refresh, mirrors DO UPDATE f.linked[userID] = true delete(f.linkCodes, code) - return rec.mcUUID, nil + return rec.mcUUID, rec.authSource, nil } // CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests diff --git a/internal/api/handlers_account.go b/internal/api/handlers_account.go index 759e665..01f0eac 100644 --- a/internal/api/handlers_account.go +++ b/internal/api/handlers_account.go @@ -34,8 +34,23 @@ const ( // linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute // force inside the TTL. linkCodeLen = 8 + + // authSource records which Yggdrasil established the in-game UUID when a code + // was minted (spec §10, dual-Yggdrasil): the official Mojang service, or a + // configured thirdparty. It is captured at mint (the only place that knows it) + // and copied onto the durable link at verify; the web side never sees the + // authentication. These mirror the link_auth_source enum (migration 0005). + authSourceMojang = "mojang" + authSourceThirdParty = "thirdparty" ) +// validAuthSource reports whether s is a recognised link_auth_source value. An +// empty string is NOT valid here — handleCreateLinkCode defaults it before this +// check, so a non-empty value reaching validation must be one we can store. +func validAuthSource(s string) bool { + return s == authSourceMojang || s == authSourceThirdParty +} + // newLinkCode returns a cryptographically random, unambiguous link code. func newLinkCode() (string, error) { buf := make([]byte, linkCodeLen) @@ -49,9 +64,13 @@ func newLinkCode() (string, error) { } // createLinkCodeRequest is the in-game /link callback body (spec §10): the -// backend reports the verified UUID of the player who ran the command. +// backend reports the verified UUID of the player who ran the command, plus how +// that UUID was authenticated (auth_source). auth_source is optional — an older +// backend that omits it falls back to the Mojang-priority default — but a value +// that IS sent must be one we can store. type createLinkCodeRequest struct { - MCUUID string `json:"mc_uuid"` + MCUUID string `json:"mc_uuid"` + AuthSource string `json:"auth_source"` } // handleCreateLinkCode mints a one-time link code for a verified in-game UUID @@ -69,13 +88,25 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) return } + // Default an omitted source to Mojang (spec §10 priority) but reject an + // unrecognised one — a typo'd source must not silently land as a stored value + // the panel will later mislabel. + authSource := req.AuthSource + if authSource == "" { + authSource = authSourceMojang + } + if !validAuthSource(authSource) { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", + "auth_source must be %q or %q", authSourceMojang, authSourceThirdParty)) + return + } code, err := newLinkCode() if err != nil { writeError(w, r, err) return } expiresAt := a.now().Add(linkCodeTTL) - if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, expiresAt); err != nil { + if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil { writeError(w, r, err) return } @@ -110,7 +141,7 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required")) return } - mcUUID, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now()) + mcUUID, authSource, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now()) switch { case errors.Is(err, ErrLinkCodeInvalid): writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "link code is invalid or expired")) @@ -124,7 +155,9 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) { return } a.audit(r, p.Email, "account.link", "") - writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": mcUUID}) + writeJSON(w, http.StatusOK, map[string]any{ + "linked": true, "mc_uuid": mcUUID, "auth_source": authSource, + }) } // handleLinkStart reports the caller's link status and how to link (spec §10, diff --git a/internal/api/handlers_account_test.go b/internal/api/handlers_account_test.go index 1f6afb5..9e9ddf8 100644 --- a/internal/api/handlers_account_test.go +++ b/internal/api/handlers_account_test.go @@ -57,8 +57,8 @@ func TestAccountLinkVertical(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String()) } - if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID { - t.Fatalf("verify body = %v, want linked:true mc_uuid:%s", b, mcUUID) + if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID || b["auth_source"] != authSourceMojang { + t.Fatalf("verify body = %v, want linked:true mc_uuid:%s auth_source:%s", b, mcUUID, authSourceMojang) } // The link is audited as account.link by the principal's Access email. if n := len(repo.audits); n != 1 || repo.audits[0].Action != "account.link" || repo.audits[0].Actor != "u1@example.net" { @@ -107,6 +107,10 @@ func TestCreateLinkCode(t *testing.T) { if rec.mcUUID != mcUUID { t.Errorf("stored mc_uuid = %q, want %q", rec.mcUUID, mcUUID) } + // An omitted auth_source defaults to the Mojang-priority source. + if rec.authSource != authSourceMojang { + t.Errorf("default authSource = %q, want %q", rec.authSource, authSourceMojang) + } if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) { t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want) } @@ -116,6 +120,24 @@ func TestCreateLinkCode(t *testing.T) { } } }) + t.Run("explicit thirdparty is stored", func(t *testing.T) { + body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}` + w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil) + if w.Code != http.StatusCreated { + t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + code, _ := acctBody(t, w)["code"].(string) + if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty { + t.Errorf("stored authSource = %q, want %q", rec.authSource, authSourceThirdParty) + } + }) + t.Run("unrecognised auth_source -> 400", func(t *testing.T) { + body := `{"mc_uuid":"` + mcUUID + `","auth_source":"litebans"}` + w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" { + t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String()) + } + }) } // TestLinkVerifyRejections is the verify failure matrix. The expired case seeds a @@ -206,6 +228,37 @@ func TestLinkVerifyIdempotent(t *testing.T) { } } +// TestLinkAuthSourcePropagates proves auth_source survives the whole §10 flow: a +// thirdparty source captured in-game at mint reaches the durable link and the +// verify response — the value the web side can never originate itself. +func TestLinkAuthSourcePropagates(t *testing.T) { + const mcUUID = "55555555-5555-5555-5555-555555555555" + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: user} + + // Mint in-game with the thirdparty Yggdrasil source. + body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}` + w := do(api.InternalHandler(), "POST", "/api/v1/internal/account/link/code", body, nil) + if w.Code != http.StatusCreated { + t.Fatalf("mint: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + code, _ := acctBody(t, w)["code"].(string) + + // Verify on the web: the response and the stored link must both carry thirdparty. + w = do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"`+code+`"}`, nil) + if w.Code != http.StatusOK { + t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if got := acctBody(t, w)["auth_source"]; got != authSourceThirdParty { + t.Errorf("verify body auth_source = %v, want %q", got, authSourceThirdParty) + } + if got := repo.linkAuthSource[mcUUID]; got != authSourceThirdParty { + t.Errorf("stored link auth_source = %q, want %q", got, authSourceThirdParty) + } +} + // TestLinkStart pins the status endpoint handleClaim's 412 points at: it reports // link state and instructions, and never mints (it has no UUID to mint against). func TestLinkStart(t *testing.T) { diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index ea7ae0f..ad0b4bf 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -58,10 +58,10 @@ func (p *PGRepo) IsLinked(ctx context.Context, userID string) (bool, error) { // driven by one authoritative clock. The code is a PRIMARY KEY; a collision on // the crypto/rand value is astronomically unlikely but surfaces as a plain // driver error (the caller can retry) rather than being masked here. -func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error { +func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error { _, err := p.db.ExecContext(ctx, - `INSERT INTO account_link_codes (code, mc_uuid, expires_at) VALUES ($1, $2, $3)`, - code, mcUUID, expiresAt) + `INSERT INTO account_link_codes (code, mc_uuid, auth_source, expires_at) VALUES ($1, $2, $3, $4)`, + code, mcUUID, authSource, expiresAt) return err } @@ -73,21 +73,21 @@ func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expire // owner's pending code. The UNIQUE(mc_uuid) constraint is the last-resort guard // against a concurrent racer that passed the SELECT; that loses to a 500, which // is acceptable for this integration-only path. -func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, error) { +func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, string, error) { tx, err := p.db.BeginTx(ctx, nil) if err != nil { - return "", err + return "", "", err } defer tx.Rollback() //nolint:errcheck // no-op after commit - var mcUUID string + var mcUUID, authSource string switch err := tx.QueryRowContext(ctx, - `SELECT mc_uuid FROM account_link_codes WHERE code = $1 AND expires_at > $2`, - code, now).Scan(&mcUUID); { + `SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`, + code, now).Scan(&mcUUID, &authSource); { case errors.Is(err, sql.ErrNoRows): - return "", ErrLinkCodeInvalid + return "", "", ErrLinkCodeInvalid case err != nil: - return "", err + return "", "", err } // If this UUID is already linked, only the same user may re-verify (idempotent); @@ -98,26 +98,31 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti case errors.Is(err, sql.ErrNoRows): // not yet linked — fall through to insert case err != nil: - return "", err + return "", "", err default: if existingUser != userID { - return "", ErrConflict + return "", "", ErrConflict } } + // Copy the code's auth_source onto the durable link. On the idempotent + // re-verify path DO UPDATE refreshes it (a player who re-linked via a different + // Yggdrasil this time gets the latest source stored), keeping the persisted + // value equal to the one returned to the caller. if _, err := tx.ExecContext(ctx, - `INSERT INTO account_links (user_id, mc_uuid) VALUES ($1, $2) - ON CONFLICT (user_id, mc_uuid) DO NOTHING`, userID, mcUUID); err != nil { - return "", fmt.Errorf("write account link: %w", err) + `INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3) + ON CONFLICT (user_id, mc_uuid) DO UPDATE SET auth_source = EXCLUDED.auth_source`, + userID, mcUUID, authSource); err != nil { + return "", "", fmt.Errorf("write account link: %w", err) } if _, err := tx.ExecContext(ctx, `DELETE FROM account_link_codes WHERE code = $1`, code); err != nil { - return "", fmt.Errorf("consume link code: %w", err) + return "", "", fmt.Errorf("consume link code: %w", err) } if err := tx.Commit(); err != nil { - return "", err + return "", "", err } - return mcUUID, nil + return mcUUID, authSource, nil } // QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited; diff --git a/internal/api/repo.go b/internal/api/repo.go index 8f2edbc..2415cf5 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -114,18 +114,21 @@ type Repo interface { IsLinked(ctx context.Context, userID string) (bool, error) // CreateLinkCode mints a one-time account-link code for an in-game player // (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the - // verified mc_uuid (online-mode=true established it); a web user binds it to - // their user_id later via VerifyLinkCode. This is internal-face only — the web - // has no verified UUID to mint against (the account_link_codes schema has no - // user_id column, which forces the in-game origin). - CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error + // verified mc_uuid (online-mode=true established it) and the authSource that + // established it (mojang|thirdparty, spec §10 dual-Yggdrasil); a web user binds + // it to their user_id later via VerifyLinkCode. This is internal-face only — the + // web has no verified UUID to mint against (the account_link_codes schema has no + // user_id column, which forces the in-game origin), nor does it see the + // authentication, which is why authSource also originates here. + CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error // VerifyLinkCode consumes a non-expired code for the logged-in user and writes // the account_links binding, atomically (spec §10: 网页 verify 填码 → 写 - // account_links). It returns the bound mc_uuid. A missing or expired code → + // account_links). It returns the bound mc_uuid and the authSource captured at + // mint (copied from the code onto the durable link). A missing or expired code → // ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict; - // re-verifying the same (user, uuid) pair is idempotent. now is the API clock so - // expiry is testable. - VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID string, err error) + // re-verifying the same (user, uuid) pair is idempotent and refreshes the stored + // authSource. now is the API clock so expiry is testable. + VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error) // QuotaAvailable reports whether the user is under their max_servers quota // (spec §9.3 step ②, evaluated before provisioning). QuotaAvailable(ctx context.Context, userID string) (bool, error) diff --git a/internal/store/migrations/0005_account_link_auth_source.sql b/internal/store/migrations/0005_account_link_auth_source.sql new file mode 100644 index 0000000..f54a804 --- /dev/null +++ b/internal/store/migrations/0005_account_link_auth_source.sql @@ -0,0 +1,17 @@ +-- Player onboarding (spec §10, dual-Yggdrasil): record HOW the in-game identity +-- authenticated when a link code was minted — 'mojang' (Mojang/official Yggdrasil, +-- the priority source) or 'thirdparty' (a configured alternate Yggdrasil). The +-- value is known only in-game at the moment online-mode auth established the UUID, +-- so it is captured on the code at mint time and copied onto the durable link at +-- verify. The web verify side never sees the authentication and cannot originate +-- it — the same constraint that puts mc_uuid (not user_id) on a code. +-- +-- DEFAULT 'mojang' backfills any code/link rows that predate this column and gives +-- a Mojang-priority default for a mint that omits the field; the mint path supplies +-- it explicitly going forward. +CREATE TYPE link_auth_source AS ENUM ('mojang','thirdparty'); + +ALTER TABLE account_link_codes + ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang'; +ALTER TABLE account_links + ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang';