refactor(api): drop dead password-era ResetMailer, reconcile passkey-unbind docs
The passwordless migration left ResetMailer (SendPasswordReset) and its API field with zero callers and no wiring; the web console authenticates via email-OTP and passkey only. Remove both, plus the now-orphaned context import that the interface was the last user of in handlers_users.go.
Reconcile the DeleteAllPasskeyCredentialsForUser docs in repo.go and pgrepo.go: they claimed there was no production caller, but 2f22027 wired the owner-tier DELETE /users/{id}/passkeys. Both now note that a complete authenticator remediation pairs the unbind with a session revoke (unbinding alone leaves the live hijacked session; revoking alone leaves a re-enrollable credential), and the OpenAPI operation carries the same guidance in a new description. Reword the stale local-password test-fake header, since the passwordless fakes carry no must_change_password field.
No behavior change. gofmt, build, and the full test tree are green; OpenAPI parity and passkey-unbind tests pass; a grep confirms ResetMailer/SendPasswordReset are gone from the Go tree.
This commit is contained in:
6 files changed
+19
-24
No files matched your search
@@ -2720,6 +2720,14 @@ paths:
|
|||||||
tags: [users]
|
tags: [users]
|
||||||
operationId: unbindUserPasskeys
|
operationId: unbindUserPasskeys
|
||||||
summary: Unbind every passkey of a user (owner only) — authenticator remediation.
|
summary: Unbind every passkey of a user (owner only) — authenticator remediation.
|
||||||
|
description: >-
|
||||||
|
Severs a compromised or planted authenticator that would otherwise outlive a
|
||||||
|
session revoke. A complete remediation pairs this with revoking the user's
|
||||||
|
sessions (DELETE /users/{id}/sessions/{hash}): unbinding the credential alone
|
||||||
|
leaves the live hijacked session, and revoking sessions alone leaves a
|
||||||
|
re-enrollable credential. It is not a lockout — the account re-enters via the
|
||||||
|
email-OTP door or op-login and re-enrolls. Removing zero passkeys is a 200
|
||||||
|
no-op, not a 404.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ type API struct {
|
|||||||
// sender. The code is never returned to the client on either path.
|
// sender. The code is never returned to the client on either path.
|
||||||
Mailer OTPMailer
|
Mailer OTPMailer
|
||||||
|
|
||||||
// ResetMailer delivers admin-generated password-reset passwords to the user's
|
|
||||||
// verified email address. Same nil→server-side-log pattern as Mailer; the
|
|
||||||
// password is never returned to the admin caller. Production wires a real sender.
|
|
||||||
ResetMailer ResetMailer
|
|
||||||
|
|
||||||
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
|
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
|
||||||
// passkey bind). It is optional: when nil the passkey register routes report 503
|
// passkey bind). It is optional: when nil the passkey register routes report 503
|
||||||
// rather than panic, so the authenticated enrollment boundary is exercised before
|
// rather than panic, so the authenticated enrollment boundary is exercised before
|
||||||
|
|||||||
@@ -605,10 +605,10 @@ func (f *fakeRepo) BackupByID(_ context.Context, id string) (*BackupRecord, erro
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- local-password auth fakes (spec §B) ----
|
// ---- staff / session auth fakes (spec §B, passwordless) ----
|
||||||
// Each method mirrors the PGRepo contract: a returned StaffUser is copied so a
|
// Each method mirrors the PGRepo contract: a returned StaffUser is copied so a
|
||||||
// test cannot mutate the stored row by reference, SessionUser re-reads the
|
// test cannot mutate the stored row by reference, SessionUser re-reads the
|
||||||
// CURRENT staff flags (so a password change clears must_change_password for live
|
// CURRENT staff row (so a role change or a deleted account takes effect on live
|
||||||
// sessions just as the PG JOIN does), and the settings/sessions semantics match.
|
// sessions just as the PG JOIN does), and the settings/sessions semantics match.
|
||||||
|
|
||||||
func (f *fakeRepo) UserByUsername(_ context.Context, username string) (*StaffUser, error) {
|
func (f *fakeRepo) UserByUsername(_ context.Context, username string) (*StaffUser, error) {
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResetMailer delivers a freshly-generated admin-reset password to the user's
|
|
||||||
// verified email address. nil means the password is logged server-side (the
|
|
||||||
// KNOWN-LIMITATION pattern from OTPMailer — production wires a real sender).
|
|
||||||
// The password is never returned to the admin caller.
|
|
||||||
type ResetMailer interface {
|
|
||||||
SendPasswordReset(ctx context.Context, email, password string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- user CRUD ----
|
// ---- user CRUD ----
|
||||||
|
|
||||||
// handleListUsers is the admin-tier user list (GET /users). It gates on
|
// handleListUsers is the admin-tier user list (GET /users). It gates on
|
||||||
|
|||||||
@@ -1020,9 +1020,9 @@ func (p *PGRepo) DeletePasskeyCredential(ctx context.Context, userID, id string)
|
|||||||
// single-credential delete this does NOT report ErrNotFound on zero rows: removing all of
|
// single-credential delete this does NOT report ErrNotFound on zero rows: removing all of
|
||||||
// a user's passkeys when they have none is a successful no-op, since "the user holds no
|
// a user's passkeys when they have none is a successful no-op, since "the user holds no
|
||||||
// passkeys" is exactly the intended post-condition. It is the remediation that stops a
|
// passkeys" is exactly the intended post-condition. It is the remediation that stops a
|
||||||
// passkey planted through a transiently-hijacked session from surviving; its original
|
// passkey planted through a transiently-hijacked session from surviving; its production
|
||||||
// caller (the change-password flow) was removed in the passwordless migration, so it is
|
// caller is the owner-tier DELETE /users/{id}/passkeys, which a complete remediation
|
||||||
// currently uncalled, retained for the account-remediation/reset path (P5, #78).
|
// pairs with a session revoke (unbinding alone leaves the live hijacked session).
|
||||||
func (p *PGRepo) DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error {
|
func (p *PGRepo) DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error {
|
||||||
_, err := p.db.ExecContext(ctx,
|
_, err := p.db.ExecContext(ctx,
|
||||||
`DELETE FROM webauthn_credentials WHERE user_id = $1`, userID)
|
`DELETE FROM webauthn_credentials WHERE user_id = $1`, userID)
|
||||||
|
|||||||
@@ -354,11 +354,12 @@ type Repo interface {
|
|||||||
DeletePasskeyCredential(ctx context.Context, userID, id string) error
|
DeletePasskeyCredential(ctx context.Context, userID, id string) error
|
||||||
// DeleteAllPasskeyCredentialsForUser unbinds every passkey a user holds — the
|
// DeleteAllPasskeyCredentialsForUser unbinds every passkey a user holds — the
|
||||||
// remediation that stops a passkey planted via a transiently-hijacked session from
|
// remediation that stops a passkey planted via a transiently-hijacked session from
|
||||||
// surviving as a standing login foothold. Its original caller, the change-password
|
// surviving as a standing login foothold. Its production caller is the owner-tier
|
||||||
// flow, was removed in the passwordless migration, so it currently has no production
|
// DELETE /users/{id}/passkeys (handleUnbindUserPasskeys); a complete remediation
|
||||||
// caller; it is retained for the account-remediation/reset path (P5, #78). Removing
|
// pairs it with a session revoke, since unbinding the credential without revoking
|
||||||
// zero rows is success, not an error — an account with no passkeys is the intended
|
// live sessions leaves the hijacked session itself, and revoking sessions without
|
||||||
// post-condition either way.
|
// unbinding leaves a re-enrollable credential. Removing zero rows is success, not an
|
||||||
|
// error — an account with no passkeys is the intended post-condition either way.
|
||||||
DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error
|
DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error
|
||||||
|
|
||||||
// ---- player game-login: username-collision reclaim (spec §B3) ----
|
// ---- player game-login: username-collision reclaim (spec §B3) ----
|
||||||
|
|||||||
Reference in new issue
Block a user