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.
This commit is contained in:
5 files changed
+182
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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.
|
||||
|
||||
+29
-5
@@ -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,7 +110,21 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
|
||||
return "", "", err
|
||||
default:
|
||||
if existingUser != userID {
|
||||
return "", "", ErrConflict
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user