Unverified Commit bb9798e3 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(api): in-game identity resolution and link takeover ignore dead accounts

UserByMCUUID now resolves only live accounts: claim, menu, wake
authorization, op-login vouch and the QR link-status poll treat a
disabled or soft-deleted link holder exactly like an unlinked UUID
instead of a retired identity. VerifyLinkCode lets a soft-deleted
link be taken over by a fresh in-game code (the deleted account is
gone, e.g. a migrated source), while a disabled holder still 409s so
the lockout is not bypassable; failed attempts still do not consume
the code. Fake repo and pgint coverage pin both branches.
parent 3ffa3f53
Loading
Loading
Loading
Loading
+22 −2
Changes for internal/api/api_test.go: 22 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -287,7 +287,13 @@ func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now ti
		return "", "", ErrLinkCodeInvalid
	}
	if existing, ok := f.links[rec.mcUUID]; ok && existing != userID {
		return "", "", ErrConflict // do not consume another user's pending code
		// A soft-deleted link's identity is unclaimed: the fresh in-game code lets a
		// live caller take it over (mirrors PGRepo). Disabled-but-not-deleted stays a
		// conflict — takeover there would bypass the lockout. Neither arm consumes
		// the code.
		if !f.seededDeleted(existing) {
			return "", "", ErrConflict
		}
	}
	f.links[rec.mcUUID] = userID
	f.linkAuthSource[rec.mcUUID] = rec.authSource // copy/refresh, mirrors DO UPDATE
@@ -626,7 +632,7 @@ func (f *fakeRepo) UUIDInAllowlist(_ context.Context, n, uuid string) (bool, err
	return f.allowUUID[n][uuid], nil
}
func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error) {
	if u, ok := f.links[uuid]; ok {
	if u, ok := f.links[uuid]; ok && !f.seededDead(u) {
		return u, nil
	}
	return "", ErrNotFound
@@ -1177,6 +1183,20 @@ func (f *fakeRepo) seededDead(id string) bool {
	return false
}

// seededDeleted is the narrower liveness query: soft-deleted only (a disabled
// account still holds its identity, mirroring VerifyLinkCode's takeover rule).
func (f *fakeRepo) seededDeleted(id string) bool {
	if f.deletedIDs[id] {
		return true
	}
	for _, su := range f.seededUsers {
		if su.view.ID == id {
			return su.detail.DeletedAt != nil
		}
	}
	return false
}

func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
	if !f.liveUserExists(userID) {
		return nil, ErrNotFound
+54 −0
Changes for internal/api/handlers_account_test.go: 54 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
@@ -252,6 +254,58 @@ func TestLinkVerifyIdempotent(t *testing.T) {
	}
}

// A link whose account was SOFT-DELETED is unclaimed: a fresh in-game code lets a
// live account take it over (the migrated-source path — retire keeps the link but
// kills the account), while a merely disabled holder keeps its identity so the
// lockout cannot be re-linked away, and neither dead link has in-game standing
// (UserByMCUUID reads it exactly like an unlinked UUID). Audit #33's in-game half.
func TestLinkVerifyTakesOverDeletedLinkOnly(t *testing.T) {
	ctx := context.Background()
	const mcGone = "55555555-5555-5555-5555-555555555555"
	const mcLocked = "66666666-6666-6666-6666-666666666666"
	user := &Principal{UserID: "u-take", Email: "[email protected]", Role: "user"}
	repo := newFakeRepo()
	repo.seedUser(UserView{ID: "u-gone", Username: "gone", Email: "[email protected]", Role: "user"})
	repo.seedUser(UserView{ID: "u-locked", Username: "locked", Email: "[email protected]", Role: "user"})
	if err := repo.DeleteUser(ctx, "u-gone", "test"); err != nil {
		t.Fatalf("DeleteUser: %v", err)
	}
	repo.links[mcGone] = "u-gone" // a retired source's link outlives the account

	if _, err := repo.UserByMCUUID(ctx, mcGone); !errors.Is(err, ErrNotFound) {
		t.Fatalf("UserByMCUUID(deleted link) = %v, want ErrNotFound (no in-game standing)", err)
	}

	repo.linkCodes["TAKEOVER"] = fakeLinkCode{mcUUID: mcGone, expiresAt: time.Unix(1_700_000_600, 0)}
	api := newTestAPI(repo, newFakeCluster())
	api.External = staticExternal{p: user}
	if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"TAKEOVER"}`, nil); w.Code != http.StatusOK {
		t.Fatalf("takeover verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
	}
	if repo.links[mcGone] != "u-take" {
		t.Errorf("links[%s] = %q after takeover, want u-take", mcGone, repo.links[mcGone])
	}

	// A disabled (not deleted) holder keeps the identity: 409, code survives, link unmoved.
	if err := repo.SetUserDisabled(ctx, "u-locked", true); err != nil {
		t.Fatalf("disable: %v", err)
	}
	repo.links[mcLocked] = "u-locked"
	repo.linkCodes["LOCKED12"] = fakeLinkCode{mcUUID: mcLocked, expiresAt: time.Unix(1_700_000_600, 0)}
	if _, err := repo.UserByMCUUID(ctx, mcLocked); !errors.Is(err, ErrNotFound) {
		t.Fatalf("UserByMCUUID(disabled link) = %v, want ErrNotFound", err)
	}
	if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"LOCKED12"}`, nil); w.Code != http.StatusConflict {
		t.Fatalf("takeover of a disabled holder: code = %d, want 409 (%s)", w.Code, w.Body.String())
	}
	if repo.links[mcLocked] != "u-locked" {
		t.Errorf("disabled holder's link moved to %q", repo.links[mcLocked])
	}
	if _, ok := repo.linkCodes["LOCKED12"]; !ok {
		t.Error("refused verify consumed the code")
	}
}

// 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.
+28 −4
Changes for internal/api/pgrepo.go: 28 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -94,8 +94,13 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
		return "", "", err
	}

	// If this UUID is already linked, only the same user may re-verify (idempotent);
	// a different user is a conflict and must not consume the code.
	// If this UUID is already linked, only the same user may re-verify (idempotent).
	// A different LIVE user is a conflict and must not consume the code. A link whose
	// account was soft-deleted is the exception: the identity is unclaimed (the
	// account is gone; e.g. a migrated source, whose retire keeps the link but is
	// otherwise dead), and the fresh in-game code proves the caller still holds this
	// UUID, so the live caller takes the link over. Disabled-but-not-deleted stays a
	// conflict — taking over a locked account's identity would bypass the lockout.
	var existingUser string
	switch err := tx.QueryRowContext(ctx,
		`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&existingUser); {
@@ -105,8 +110,22 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
		return "", "", err
	default:
		if existingUser != userID {
			var linkedDeleted bool
			if err := tx.QueryRowContext(ctx,
				`SELECT deleted_at IS NOT NULL FROM users WHERE id = $1`,
				existingUser).Scan(&linkedDeleted); err != nil {
				return "", "", err
			}
			if !linkedDeleted {
				return "", "", ErrConflict
			}
			if _, err := tx.ExecContext(ctx,
				`UPDATE account_links SET user_id = $1, auth_source = $2, verified_at = now()
				  WHERE mc_uuid = $3`,
				userID, authSource, mcUUID); err != nil {
				return "", "", fmt.Errorf("take over retired link: %w", err)
			}
		}
	}

	// Copy the code's auth_source onto the durable link. On the idempotent
@@ -513,11 +532,16 @@ func (p *PGRepo) UUIDInAllowlist(ctx context.Context, name, mcUUID string) (bool
}

// UserByMCUUID resolves a verified in-game UUID to its linked user_id (spec §10
// account_links), or ErrNotFound when the UUID is not linked to any account.
// account_links), or ErrNotFound when the UUID is not linked to any account. A
// link whose account is dead reads the same as no link at all (audit #33), so the
// in-game doors never act as a retired identity.
func (p *PGRepo) UserByMCUUID(ctx context.Context, mcUUID string) (string, error) {
	var userID string
	switch err := p.db.QueryRowContext(ctx,
		`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); {
		`SELECT al.user_id FROM account_links al
		   JOIN users u ON u.id = al.user_id
		  WHERE al.mc_uuid = $1 AND u.disabled = false AND u.deleted_at IS NULL`,
		mcUUID).Scan(&userID); {
	case errors.Is(err, sql.ErrNoRows):
		return "", ErrNotFound
	case err != nil:
+4 −0
Changes for internal/api/repo.go: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -251,6 +251,10 @@ type Repo interface {
	// (spec §10 account_links), or ErrNotFound when the UUID is not linked. The
	// internal-face wake uses it to apply the owner bypass for a player known only
	// by UUID; an unlinked UUID simply falls through to the autostartPolicy gate.
	// Only a LIVE account resolves (audit #33): a link whose account is disabled or
	// soft-deleted carries no standing on the in-game doors — claim, menu, wake
	// authorization, op-login vouch and the QR link-status poll all read a dead
	// account exactly like an unlinked UUID, never as a retired identity.
	UserByMCUUID(ctx context.Context, mcUUID string) (userID string, err error)
	// RecordJoin updates last_active_at, clears reaper warnings, and auto-appends
	// the UUID to the allowlist (spec §7 join-event, §9.4).
+73 −0
Changes for internal/pgint/pgint_test.go: 73 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -532,6 +532,20 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
	if err := repo.LinkAccount(ctx, u.ID, mc, "mojang"); err != nil {
		t.Fatalf("LinkAccount: %v", err)
	}
	// A disabled account's intact link carries no in-game standing (the doors read
	// it like an unlinked UUID), and resolves again once re-enabled.
	if err := repo.SetUserDisabled(ctx, u.ID, true); err != nil {
		t.Fatalf("disable with link: %v", err)
	}
	if _, err := repo.UserByMCUUID(ctx, mc); !errors.Is(err, api.ErrNotFound) {
		t.Fatalf("UserByMCUUID(disabled) = %v, want ErrNotFound", err)
	}
	if err := repo.SetUserDisabled(ctx, u.ID, false); err != nil {
		t.Fatalf("re-enable with link: %v", err)
	}
	if id, err := repo.UserByMCUUID(ctx, mc); err != nil || id != u.ID {
		t.Fatalf("UserByMCUUID(re-enabled) = %q, %v; want %q", id, err, u.ID)
	}
	if _, err := db.ExecContext(ctx,
		`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key) VALUES ($1,$2,$3,'pk')`,
		"cred-"+suffix(t), u.ID, "cid-"+suffix(t)); err != nil {
@@ -564,6 +578,65 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
	}
}

// VerifyLinkCode's takeover rule: a fresh in-game code (proof the caller holds the
// UUID) lets a live account take over a link whose account was SOFT-DELETED — the
// migrated-source case, whose retire keeps the link but kills the account — while a
// merely DISABLED holder keeps its identity (takeover there would bypass the
// lockout) and the refused code survives.
func TestVerifyLinkCodeTakesOverDeletedLink(t *testing.T) {
	ctx := context.Background()
	now := mustNow()

	// The retired source: linked, then retired the way RedeemMigration retires one.
	src := newUser(t, "user", "retire-src")
	mc := testUUID(t)
	if err := repo.LinkAccount(ctx, src.ID, mc, "mojang"); err != nil {
		t.Fatalf("LinkAccount(src): %v", err)
	}
	if _, err := db.ExecContext(ctx,
		`UPDATE users SET disabled = true, deleted_at = now() WHERE id = $1`, src.ID); err != nil {
		t.Fatalf("retire src: %v", err)
	}

	taker := newUser(t, "user", "taker")
	code := "tk-" + suffix(t)
	if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(5*time.Minute)); err != nil {
		t.Fatalf("CreateLinkCode: %v", err)
	}
	if _, _, err := repo.VerifyLinkCode(ctx, taker.ID, code, now); err != nil {
		t.Fatalf("takeover verify: %v", err)
	}
	if id, err := repo.UserByMCUUID(ctx, mc); err != nil || id != taker.ID {
		t.Fatalf("after takeover UserByMCUUID = %q, %v; want %q", id, err, taker.ID)
	}

	// A disabled (not deleted) holder keeps the identity; the conflict arm must not
	// consume the code and must leave the link where it was.
	locked := newUser(t, "user", "locked")
	mc2 := testUUID(t)
	if err := repo.LinkAccount(ctx, locked.ID, mc2, "mojang"); err != nil {
		t.Fatalf("LinkAccount(locked): %v", err)
	}
	if err := repo.SetUserDisabled(ctx, locked.ID, true); err != nil {
		t.Fatalf("disable locked: %v", err)
	}
	code2 := "lk-" + suffix(t)
	if err := repo.CreateLinkCode(ctx, code2, mc2, "mojang", now.Add(5*time.Minute)); err != nil {
		t.Fatalf("CreateLinkCode(code2): %v", err)
	}
	if _, _, err := repo.VerifyLinkCode(ctx, taker.ID, code2, now); !errors.Is(err, api.ErrConflict) {
		t.Fatalf("takeover of a disabled holder = %v, want ErrConflict", err)
	}
	var stillLinked string
	if err := db.QueryRowContext(ctx, `SELECT user_id FROM account_links WHERE mc_uuid = $1`, mc2).Scan(&stillLinked); err != nil || stillLinked != locked.ID {
		t.Fatalf("disabled holder's link moved to %q, %v; want %q", stillLinked, err, locked.ID)
	}
	var codeRows int
	if err := db.QueryRowContext(ctx, `SELECT count(*) FROM account_link_codes WHERE code = $1`, code2).Scan(&codeRows); err != nil || codeRows != 1 {
		t.Fatalf("refused code rows = %d, %v; want 1 (not consumed)", codeRows, err)
	}
}

// The owner tier the panel gates on must actually be WRITTEN: until this
// contract had a test, every provisioning path wrote 'admin', so the whole
// owner surface (user administration) was unreachable in a fresh install.