feat(auth): add owner-tier passkey-unbind remediation endpoint
Add DELETE /api/v1/users/{id}/passkeys (owner-only) to unbind every passkey a
target account holds — the authenticator remediation that stops a passkey planted
or retained via a transiently-hijacked session from surviving as a standing login
foothold. It wires the previously-uncalled DeleteAllPasskeyCredentialsForUser and
is deliberately not a lockout: the account re-enters via the email-OTP door
(players) or op-login's in-game approval (staff), then re-enrolls. Documented in
the OpenAPI, so the served/documented parity gate covers it.
Remove RevokeUserSessionsExcept: a change-password-era orphan with no callers
since the passwordless migration. Its keep-one ("log out my other devices")
semantics is inherently self-service, and no such slice is on the roadmap; the
admin remediation path already uses RevokeAllUserSessions.
This commit is contained in:
6 files changed
+103
-23
No files matched your search
@@ -662,14 +662,6 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) RevokeUserSessionsExcept(_ context.Context, userID, keepTokenHash string) error {
|
||||
for h, s := range f.sessions {
|
||||
if s.userID == userID && h != keepTokenHash {
|
||||
s.revoked = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) {
|
||||
if v, ok := f.settings[key]; ok {
|
||||
return v, nil
|
||||
@@ -1228,6 +1220,56 @@ func TestExternalFaceRequiresPrincipal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnbindUserPasskeys proves the authenticator-remediation door
|
||||
// (DELETE /users/{id}/passkeys) severs every passkey a target account holds, is
|
||||
// gated to the owner role (an Operator-grade admin is refused, so it is stricter
|
||||
// than the app-admin surface), and treats an account with no passkeys as a 200
|
||||
// no-op rather than a 404 — remediation must be idempotent.
|
||||
func TestUnbindUserPasskeys(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
|
||||
// Seed the target account with two bound passkeys.
|
||||
ctx := context.Background()
|
||||
for _, id := range []string{"pk1", "pk2"} {
|
||||
if err := repo.CreatePasskeyCredential(ctx, PasskeyCredential{
|
||||
ID: id, UserID: "victim", CredentialID: "cred-" + id, PublicKey: "pub",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "owner", ViaAdminAccess: true}
|
||||
|
||||
t.Run("owner unbinds every passkey", func(t *testing.T) {
|
||||
api.External = staticExternal{p: owner}
|
||||
w := do(api.ExternalHandler(), "DELETE", "/api/v1/users/victim/passkeys", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
creds, _ := repo.PasskeyCredentialsForUser(ctx, "victim")
|
||||
if len(creds) != 0 {
|
||||
t.Fatalf("passkeys remaining = %d, want 0", len(creds))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no passkeys is a 200 no-op, not a 404", func(t *testing.T) {
|
||||
api.External = staticExternal{p: owner}
|
||||
w := do(api.ExternalHandler(), "DELETE", "/api/v1/users/ghost/passkeys", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an Operator-grade admin is refused (owner-only)", func(t *testing.T) {
|
||||
api.External = staticExternal{p: &Principal{UserID: "op1", Role: "admin", ViaAdminAccess: true}}
|
||||
w := do(api.ExternalHandler(), "DELETE", "/api/v1/users/victim/passkeys", "", nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMeIdentity proves GET /api/v1/me reports the server-computed identity the
|
||||
// panel uses to gate its Admin / SysAdmin navigation. The load-bearing assertion
|
||||
// is the third subtest: is_admin tracks Principal.IsAdmin(), so the admin ROLE is
|
||||
|
||||
Reference in new issue
Block a user