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

fix(api): dead accounts cannot log in, hold sessions, or keep identity assets

parent ba9d98f7
Loading
Loading
Loading
Loading
+60 −1
Changes for internal/api/api_test.go: 60 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -103,6 +103,10 @@ type fakeRepo struct {
	// user admin fakes
	seededUsers []seededUser
	fakeQuotas  map[string]*QuotaView
	// deletedIDs remembers soft-deleted user ids: DeleteUser drops the row from
	// seededUsers (so listings hide it, mirroring the WHERE deleted_at IS NULL
	// query), and this set keeps the account dead for the liveness guards.
	deletedIDs map[string]bool
	// pingErr, when non-nil, is returned by Ping to simulate DB liveness check
	// failures in /readyz tests.
	pingErr error
@@ -233,6 +237,7 @@ func newFakeRepo() *fakeRepo {
		discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
		fakeQuotas:             map[string]*QuotaView{},
		migrations:             map[string]*fakeMigration{},
		deletedIDs:             map[string]bool{},
	}
}

@@ -315,6 +320,9 @@ func (f *fakeRepo) RedeemPlayerBindCode(_ context.Context, newUserID, code strin
				return "", "", "", ErrPlayerBindForbidden // staff must use op.console; do not consume
			}
		}
		if f.seededDead(existing) {
			return "", "", "", ErrPlayerAccountRetired // dead account; do not consume
		}
		delete(f.linkCodes, code)
		return existing, rec.mcUUID, rec.authSource, nil
	}
@@ -803,6 +811,9 @@ func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Tim
	}
	for _, u := range f.staff {
		if u.ID == s.userID {
			if f.seededDead(u.ID) {
				return nil, ErrNotFound
			}
			return &SessionedUser{
				ID: u.ID, Email: u.Email, Role: u.Role,
			}, nil
@@ -895,11 +906,29 @@ func (f *fakeRepo) ListUsers(_ context.Context, opts ListUsersOpts) ([]UserView,
}

func (f *fakeRepo) UserDetail(_ context.Context, userID string) (*UserDetail, error) {
	deletedAt := time.Unix(1_700_000_000, 0)
	for _, su := range f.seededUsers {
		if su.view.ID == userID {
			return &su.detail, nil
		}
	}
	// Legacy fixtures seeded only into f.staff are live accounts (nothing marked
	// them disabled or deleted), so detail reads must resolve them too — the
	// liveness guards (discoverable login, owner protection) treat "unknown" as a
	// fault, and these fixtures are known.
	for _, u := range f.staff {
		if u.ID == userID {
			if f.deletedIDs[userID] {
				// A soft-deleted account still HAS a detail row; it is flagged, not gone.
				return &UserDetail{UserView: UserView{
					ID: u.ID, Username: u.Username, Role: u.Role, Disabled: true,
				}, DeletedAt: &deletedAt}, nil
			}
			return &UserDetail{UserView: UserView{
				ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
			}}, nil
		}
	}
	return nil, ErrNotFound
}

@@ -959,6 +988,20 @@ func (f *fakeRepo) DeleteUser(_ context.Context, userID, _ string) error {
	for i, su := range f.seededUsers {
		if su.view.ID == userID {
			f.seededUsers = append(f.seededUsers[:i], f.seededUsers[i+1:]...)
			f.deletedIDs[userID] = true
			// Mirror PGRepo: deletion severs the account's identity assets so the
			// closed account keeps neither a login credential nor a MC-UUID claim.
			for uuid, uid := range f.links {
				if uid == userID {
					delete(f.links, uuid)
					delete(f.linkAuthSource, uuid)
				}
			}
			for cid, cred := range f.passkeyCreds {
				if cred.UserID == userID {
					delete(f.passkeyCreds, cid)
				}
			}
			return nil
		}
	}
@@ -1118,6 +1161,22 @@ func (f *fakeRepo) liveUserExists(id string) bool {
	return false
}

// seededDead mirrors PGRepo's liveness filters (audit #33): a seeded user that was
// disabled or soft-deleted is dead for the login doors and session validation. A
// fixture that was never seeded (legacy tests put it straight into f.staff) is
// treated as live, matching the fakes' pre-existing behavior.
func (f *fakeRepo) seededDead(id string) bool {
	if f.deletedIDs[id] {
		return true
	}
	for _, su := range f.seededUsers {
		if su.view.ID == id {
			return su.view.Disabled || su.detail.DeletedAt != nil
		}
	}
	return false
}

func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
	if !f.liveUserExists(userID) {
		return nil, ErrNotFound
@@ -1212,7 +1271,7 @@ func (f *fakeRepo) LinkAccount(_ context.Context, userID, mcUUID, authSource str
// is indistinguishable from no account: both yield ErrNotFound.
func (f *fakeRepo) UserByEmail(_ context.Context, email string) (*StaffUser, error) {
	for _, u := range f.staff {
		if u.EmailVerified && strings.EqualFold(u.Email, email) {
		if u.EmailVerified && strings.EqualFold(u.Email, email) && !f.seededDead(u.ID) {
			su := *u
			return &su, nil
		}
+7 −0
Changes for internal/api/errors.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -57,6 +57,13 @@ var (
	// provably never mints a session for a staff identity. It is distinct from
	// ErrConflict so the handler answers 403 (wrong door) rather than 409.
	ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account")
	// ErrPlayerAccountRetired means a Bind-Code redemption resolved to an account the
	// platform has closed: an owner soft-deleted it, or it is disabled (locked out).
	// Reusing the row would mint a fresh session for a dead account — the same
	// resurrection the login doors refuse by resolving only live accounts — so the
	// redeemer gets an explicit 403 instead. The code is NOT consumed, so re-enabling
	// the account and retrying still works within the code's TTL.
	ErrPlayerAccountRetired = errors.New("player account is retired or disabled")
	// ErrEmailTaken means a verified email would collide with another account's
	// already-verified address (spec §B email-first login foundation; the
	// users_verified_email_unique index ships in migration 0020).
+93 −0
Changes for internal/api/handlers_auth_email_test.go: 93 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"
	"testing"
@@ -618,3 +620,94 @@ func TestLoginEmailStartFailedDeliveryReleasesCooldown(t *testing.T) {
		t.Errorf("mailer calls = %d, want 2 (one failed, one delivered)", mailer.calls)
	}
}

// A disabled or soft-deleted account is DEAD at every door: the pre-session
// resolvers refuse it (uniformly, so the door stays no-oracle), a session that
// was live a moment ago stops authenticating, DeleteUser severs the account's
// passkeys and Minecraft links, and the bind door refuses to reuse the retired
// identity instead of minting a session for it. Audit #33 found the opposite
// live: a deleted user re-logged-in through the email door and GET /me answered
// 200 — deletion and the disable lockout were both bypassable by logging in again.
func TestDeadAccountsCannotLogInOrKeepSessions(t *testing.T) {
	ctx := context.Background()
	now := time.Unix(1_700_000_000, 0)

	repo := newFakeRepo()
	repo.settings[LocalAuthEnabledKey] = []byte("true")
	repo.seedUser(UserView{ID: "u-dead", Username: "dead", Email: "[email protected]", Role: "user"})
	repo.staff["dead"].EmailVerified = true
	mailer := &captureMailer{}
	api := newTestAPI(repo, newFakeCluster())
	api.Mailer = mailer
	eh := api.ExternalHandler()

	// Control: alive — the door resolves the account and mails a real code, and a
	// session minted for it authenticates.
	if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
		t.Fatalf("alive start: code = %d, want 202 (%s)", w.Code, w.Body.String())
	}
	if mailer.calls != 1 {
		t.Fatalf("alive start mailed %d codes, want 1", mailer.calls)
	}
	repo.sessions["h-live"] = &fakeSession{userID: "u-dead", expiresAt: now.Add(time.Hour)}
	if _, err := repo.SessionUser(ctx, "h-live", now); err != nil {
		t.Fatalf("live SessionUser: %v", err)
	}

	// Disabled: the start is neutral (no mail), verify refuses, session dies.
	if err := repo.SetUserDisabled(ctx, "u-dead", true); err != nil {
		t.Fatalf("disable: %v", err)
	}
	// The alive start's reservation must not mask the neutral branch: clear the
	// throttle's window (test-only; the limiter itself is rebuilt lazily once).
	lim := api.otpLimiter()
	lim.mu.Lock()
	lim.last = map[string]time.Time{}
	lim.mu.Unlock()
	if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
		t.Fatalf("disabled start: code = %d, want 202 neutral (%s)", w.Code, w.Body.String())
	}
	if mailer.calls != 1 {
		t.Fatalf("disabled start mailed a code (%d calls) — a dead account must resolve to nothing", mailer.calls)
	}
	if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"000000"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
		t.Fatalf("disabled verify: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
	}
	if _, err := repo.SessionUser(ctx, "h-live", now); !errors.Is(err, ErrNotFound) {
		t.Fatalf("disabled SessionUser = %v, want ErrNotFound", err)
	}

	// Deleted: same refusals; assets severed (links released, passkeys dropped).
	if err := repo.SetUserDisabled(ctx, "u-dead", false); err != nil {
		t.Fatalf("re-enable: %v", err)
	}
	uuid := "11111111-2222-3333-4444-555555555555"
	repo.links[uuid] = "u-dead"
	repo.passkeyCreds["pk-1"] = PasskeyCredential{ID: "pk-1", UserID: "u-dead", CredentialID: "cred-1"}
	if err := repo.DeleteUser(ctx, "u-dead", "test"); err != nil {
		t.Fatalf("delete: %v", err)
	}
	if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"000000"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
		t.Fatalf("deleted verify: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
	}
	if _, err := repo.SessionUser(ctx, "h-live", now); !errors.Is(err, ErrNotFound) {
		t.Fatalf("deleted SessionUser = %v, want ErrNotFound", err)
	}
	if _, ok := repo.links[uuid]; ok {
		t.Error("DeleteUser left the Minecraft link: the UUID stays claimed forever")
	}
	if _, ok := repo.passkeyCreds["pk-1"]; ok {
		t.Error("DeleteUser left the passkey: a login credential outlives the account")
	}

	// The bind door refuses to reuse the retired identity (a stale link that
	// predates the fix, or a username squatted by the deleted row).
	repo.links[uuid] = "u-dead"
	repo.linkCodes["CODE1234"] = fakeLinkCode{mcUUID: uuid, authSource: "mojang", expiresAt: now.Add(time.Hour)}
	if _, _, _, err := repo.RedeemPlayerBindCode(ctx, "u-new", "CODE1234", now); !errors.Is(err, ErrPlayerAccountRetired) {
		t.Fatalf("bind redeem onto a deleted account = %v, want ErrPlayerAccountRetired", err)
	}
	if _, ok := repo.linkCodes["CODE1234"]; !ok {
		t.Error("refused redeem consumed the code; re-enabling the account must stay retryable within TTL")
	}
}
+8 −0
Changes for internal/api/handlers_onboard.go: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -109,6 +109,14 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
		writeError(w, r, newError(http.StatusForbidden, "staff_account",
			"that Minecraft account belongs to staff; sign in at the operator console"))
		return
	case errors.Is(err, ErrPlayerAccountRetired):
		// The linked Felis account is disabled or soft-deleted: the door refuses to
		// reuse it, because minting a session here would resurrect the account the
		// owner just retired (audit #33). The code survives, so re-enabling the
		// account and retrying within its TTL still works.
		writeError(w, r, newError(http.StatusForbidden, "account_retired",
			"this Minecraft account's Felis account is disabled or deleted; contact the operator"))
		return
	case err != nil:
		writeError(w, r, err)
		return
+7 −0
Changes for internal/api/handlers_passkey_discoverable.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -148,6 +148,13 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
		if err != nil {
			return PasskeyUser{}, err
		}
		// A disabled or soft-deleted account must not complete a login even when it
		// still holds a credential (UserByID is an unfiltered lookup shared with admin
		// reads, so the liveness check lives here, at the door). Fail closed with the
		// same opaque outcome as an unknown handle (audit #33).
		if d, err := a.Repo.UserDetail(r.Context(), u.ID); err != nil || d.Disabled || d.DeletedAt != nil {
			return PasskeyUser{}, ErrNotFound
		}
		creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
		if err != nil {
			return PasskeyUser{}, err
Loading