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

fix(passkey): 删除 passkey 先确认并显示名称与注册时间,邮箱未验证时后端拒删最后一把,错误内联显示

parent 90c39afb
Loading
Loading
Loading
Loading
+8 −1
Changes for docs/openapi.yaml: 8 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -4142,7 +4142,9 @@ paths:
      description: >
        Removes a passkey scoped to the authenticated principal, so a caller can only
        unbind their OWN credential. An unknown or cross-user id is a 404; it never
        silently no-ops as success.
        silently no-ops as success. The account's only passkey cannot be removed while
        its email is unverified (409 last_passkey): it is then the account's only
        durable way in.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4162,6 +4164,11 @@ paths:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: last_passkey — this is the only passkey and the email is unverified.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/migrate:
    get:
+21 −4
Changes for internal/api/api_test.go: 21 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -539,14 +539,31 @@ func (f *fakeRepo) PasskeyCredentialsForUser(_ context.Context, userID string) (
}

// DeletePasskeyCredential mirrors PGRepo: scoped to userID so a caller can only unbind
// their OWN credential; no matching (user, id) row → ErrNotFound.
// their OWN credential; no matching (user, id) row → ErrNotFound; the last passkey of
// a user whose email is unverified (or who has no user row here) → ErrLastPasskey.
func (f *fakeRepo) DeletePasskeyCredential(_ context.Context, userID, id string) error {
	if c, ok := f.passkeyCreds[id]; ok && c.UserID == userID {
	c, ok := f.passkeyCreds[id]
	if !ok || c.UserID != userID {
		return ErrNotFound
	}
	total := 0
	for _, other := range f.passkeyCreds {
		if other.UserID == userID {
			total++
		}
	}
	verified := false
	for _, u := range f.staff {
		if u.ID == userID {
			verified = u.EmailVerified
		}
	}
	if total == 1 && !verified {
		return ErrLastPasskey
	}
	delete(f.passkeyCreds, id)
	return nil
}
	return ErrNotFound
}

// DeleteAllPasskeyCredentialsForUser mirrors PGRepo: unbind every passkey the user holds,
// and removing zero is a successful no-op (never ErrNotFound).
+6 −0
Changes for internal/api/errors.go: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -57,6 +57,12 @@ var (
	// finish endpoint exists; the ceremony state is gone (never begun, already
	// consumed, or expired) — so handlers map it to 400, not 404.
	ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired")
	// ErrLastPasskey means a passkey delete would remove the account's only one while
	// its email is unverified. That passkey is then the account's only durable way
	// in (setupRequired: no verified email and no passkey puts it back behind the
	// setup gate, and a staff account has no other self-service door at all), so the
	// delete is refused; handlers map it to 409 last_passkey.
	ErrLastPasskey = errors.New("cannot remove the only passkey of an account without a verified email")
	// ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF
	// account (admin or owner), which the player-console bootstrap refuses
	// (console-tier access model). Staff authenticate at op.console behind Zero Trust,
+7 −0
Changes for internal/api/handlers_passkey.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -400,6 +400,8 @@ func (a *API) handlePasskeyList(w http.ResponseWriter, r *http.Request) {
// handlePasskeyDelete unbinds one of the caller's passkeys (spec §14, external app
// face). The delete is scoped to the principal, so a caller can only remove their OWN
// credential; an unknown or cross-user id → 404 (it never silently no-ops as success).
// The last passkey of an account without a verified email → 409 last_passkey: it is
// that account's only durable way in (ErrLastPasskey).
func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
	p := principalFromContext(r.Context())
	id := r.PathValue("id")
@@ -412,6 +414,11 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
			writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey"))
			return
		}
		if errors.Is(err, ErrLastPasskey) {
			writeError(w, r, newError(http.StatusConflict, "last_passkey",
				"this is your only passkey and your email is not verified; add another passkey or verify an email first"))
			return
		}
		writeError(w, r, err)
		return
	}
+61 −0
Changes for internal/api/handlers_passkey_test.go: 61 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -46,6 +46,8 @@ func plantPasskeyChallenge(repo *fakeRepo, id string, expiresAt time.Time, sessi
func TestPasskeyRegisterVertical(t *testing.T) {
	user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
	repo := newFakeRepo()
	// A verified email keeps a door open, so step 5 may remove the only passkey.
	repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "[email protected]", Role: "user", EmailVerified: true}
	v := &fakePasskeyVerifier{
		options: json.RawMessage(`{"publicKey":{"challenge":"Y2hhbGxlbmdl"}}`),
		credential: VerifiedCredential{
@@ -294,6 +296,65 @@ func TestPasskeyDeleteScoping(t *testing.T) {
	}
}

// TestPasskeyDeleteLastGuard pins the last-passkey guard: without a verified email
// the only passkey is the account's way in, so its delete is a 409 that leaves it
// bound; a second passkey or a verified email lets the delete through.
func TestPasskeyDeleteLastGuard(t *testing.T) {
	cred := func(id string) PasskeyCredential {
		return PasskeyCredential{ID: id, UserID: "u1", CredentialID: "c-" + id, CreatedAt: frozenNow}
	}
	for _, tc := range []struct {
		name     string
		verified bool
		creds    []string
		want     int
	}{
		{"only passkey, email unverified", false, []string{"a"}, http.StatusConflict},
		{"only passkey, email verified", true, []string{"a"}, http.StatusNoContent},
		{"two passkeys, email unverified", false, []string{"a", "b"}, http.StatusNoContent},
	} {
		t.Run(tc.name, func(t *testing.T) {
			for _, role := range []string{"user", "admin"} {
				user := &Principal{UserID: "u1", Email: "[email protected]", Role: role}
				repo := newFakeRepo()
				repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "[email protected]", Role: role, EmailVerified: tc.verified}
				for _, id := range tc.creds {
					repo.passkeyCreds[id] = cred(id)
				}
				eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
				w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", nil)
				if w.Code != tc.want {
					t.Fatalf("%s: code = %d body %s, want %d", role, w.Code, w.Body.String(), tc.want)
				}
				_, kept := repo.passkeyCreds["a"]
				if tc.want == http.StatusConflict {
					if got := decodeErr(t, w); got != "last_passkey" {
						t.Errorf("%s: error code = %q, want last_passkey", role, got)
					}
					if !kept {
						t.Errorf("%s: a refused delete must leave the passkey bound", role)
					}
				} else if kept {
					t.Errorf("%s: an allowed delete must remove the passkey", role)
				}
			}
		})
	}

	// Removing one of two leaves the other as the last one, which is then guarded.
	user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
	repo := newFakeRepo()
	repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "[email protected]", Role: "user"}
	repo.passkeyCreds["a"], repo.passkeyCreds["b"] = cred("a"), cred("b")
	eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
	if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", nil); w.Code != http.StatusNoContent {
		t.Fatalf("first delete: code = %d, want 204", w.Code)
	}
	if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/b", "", nil); w.Code != http.StatusConflict {
		t.Fatalf("second delete: code = %d, want 409 (it is now the last one)", w.Code)
	}
}

// TestPasskeyDeleteUnknown pins the unknown-id path: deleting an id that does not exist
// is a 404, never a silent 204.
func TestPasskeyDeleteUnknown(t *testing.T) {
Loading