feat(passkey): add WebAuthn login/assertion crypto adapter
Build the assertion (login) half of the WebAuthn ceremony crypto in the internal/passkey adapter, Oracle-verified against a virtual authenticator. - BeginLogin/FinishLogin over go-webauthn BeginLogin/ValidateLogin, username-first (allowCredentials scoped to the known user's bound passkeys). Discoverable/usernameless login stays out of scope: the enrolled credentials are non-resident and the challenge store is user-keyed (migration 0007), so it would need a future migration. - WebAuthnCredentials() now populates the stored COSE public key and signature counter (assertion validation needs both to verify the signature and detect clones); enrollment ignores them, so the change is backward-compatible and the enrollment tests guard it. - VerifiedAssertion seam output: which credential signed plus the raw signature counter. Clone/regression policy is deliberately NOT here — the counter is a ceremony fact and the future handler, which holds the previously stored counter, decides reject/warn. Scope: crypto adapter only. The login HTTP handlers, session minting, and the panel.* relying-party boundary/tier decision remain a deferred slice (no unauthenticated login route is added). BeginLogin/FinishLogin live on the concrete adapter, not the api.PasskeyVerifier interface, which grows only when a handler consumes them. Tests (virtualwebauthn): a real enrollment chained into a real assertion exercises the COSE public-key decode path and surfaces the advanced signature counter, plus origin-mismatch and unbound-credential rejection.
This commit is contained in:
3 files changed
+237
-20
No files matched your search
@@ -16,12 +16,16 @@ import (
|
|||||||
// ceremony — and manages the credentials they have bound. Email-OTP (handlers_email_otp.go)
|
// ceremony — and manages the credentials they have bound. Email-OTP (handlers_email_otp.go)
|
||||||
// stays the fallback factor, so a player with no passkey is never locked out.
|
// stays the fallback factor, so a player with no passkey is never locked out.
|
||||||
//
|
//
|
||||||
// Scope: ENROLLMENT only. The login/assertion path (proving a passkey to mint or
|
// Scope of the HANDLERS in this file: ENROLLMENT only. Every ceremony here rides on a
|
||||||
// elevate a session from an unauthenticated state) is a deferred slice — the panel.*
|
// known principal — the challenge is bound to the caller's user_id and the finish
|
||||||
// passkey relying-party boundary is a later decision (see migration 0007). So every
|
// verifies against the server-stashed SessionData, never a client-echoed challenge. The
|
||||||
// ceremony here rides on a known principal: the challenge is bound to the caller's
|
// login/assertion path (proving a passkey to mint or elevate a session from an
|
||||||
// user_id and the finish verifies against the server-stashed SessionData, never a
|
// UNauthenticated state) has its cryptographic half built and Oracle-verified in the
|
||||||
// client-echoed challenge.
|
// adapter (internal/passkey BeginLogin/FinishLogin, against a virtual authenticator),
|
||||||
|
// and its persist-ready output shape is VerifiedAssertion below — but the login HTTP
|
||||||
|
// handlers, the session minting, and the panel.* passkey relying-party boundary/tier
|
||||||
|
// decision (see migration 0007) are a deferred slice: this file adds no unauthenticated
|
||||||
|
// login route.
|
||||||
//
|
//
|
||||||
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
|
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
|
||||||
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
|
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
|
||||||
@@ -85,6 +89,22 @@ type VerifiedCredential struct {
|
|||||||
AAGUID string
|
AAGUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifiedAssertion is the output of a finished LOGIN (assertion) ceremony: which of the
|
||||||
|
// user's bound credentials proved itself and the signature counter the authenticator
|
||||||
|
// reported. Like VerifiedCredential it carries no secret. SignCount is the raw ceremony
|
||||||
|
// fact, NOT a policy verdict: the handler that eventually consumes this holds the
|
||||||
|
// previously-stored counter and decides whether a non-increase is a cloned-authenticator
|
||||||
|
// signal — the verifier deliberately does not, so clone policy lives in one place with
|
||||||
|
// the stored state. SignCount is legitimately 0 for authenticators that keep no counter.
|
||||||
|
//
|
||||||
|
// The login handlers do not exist yet (see the file header): this is the stable seam
|
||||||
|
// output the production adapter (internal/passkey) already produces and its Oracle test
|
||||||
|
// already asserts on, so wiring the handlers later needs no reshaping here.
|
||||||
|
type VerifiedAssertion struct {
|
||||||
|
CredentialID string // base64url(raw credential id) — which bound credential signed
|
||||||
|
SignCount uint32
|
||||||
|
}
|
||||||
|
|
||||||
// errPasskeyUnavailable is returned when the WebAuthn verifier is not configured on
|
// errPasskeyUnavailable is returned when the WebAuthn verifier is not configured on
|
||||||
// this api instance, so the begin/finish ceremony routes answer 503 rather than panic.
|
// this api instance, so the begin/finish ceremony routes answer 503 rather than panic.
|
||||||
var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable",
|
var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable",
|
||||||
|
|||||||
@@ -6,9 +6,15 @@
|
|||||||
// this package imports api for the seam types; api never imports this package, which is
|
// this package imports api for the seam types; api never imports this package, which is
|
||||||
// what keeps the seam (and the api test suite's fake verifier) honest.
|
// what keeps the seam (and the api test suite's fake verifier) honest.
|
||||||
//
|
//
|
||||||
// Scope: ENROLLMENT only, matching handlers_passkey.go. This wraps BeginRegistration and
|
// Scope: the full WebAuthn ceremony crypto — both the credential-creation (enrollment:
|
||||||
// CreateCredential (the credential-creation ceremony). The login/assertion path
|
// BeginRegistration/FinishRegistration over go-webauthn's BeginRegistration/CreateCredential)
|
||||||
// (BeginLogin/ValidateLogin) is a deferred slice and is intentionally not adapted here.
|
// and the assertion (login: BeginLogin/FinishLogin over BeginLogin/ValidateLogin) halves.
|
||||||
|
// Both are Oracle-verified in verifier_test.go against a virtual authenticator. Only the
|
||||||
|
// enrollment half is wired to HTTP handlers today (handlers_passkey.go); the login
|
||||||
|
// handlers, session minting, and the panel.* relying-party boundary are a deferred slice,
|
||||||
|
// so BeginLogin/FinishLogin here have no api-package caller yet. They are added to the
|
||||||
|
// concrete adapter (not the api.PasskeyVerifier interface) precisely so the crypto is
|
||||||
|
// built and verified now while the interface grows only when a handler consumes it.
|
||||||
package passkey
|
package passkey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -118,6 +124,64 @@ func (v *Verifier) FinishRegistration(user api.PasskeyUser, sessionData []byte,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BeginLogin starts an assertion (login) ceremony for a KNOWN user. It is username-first
|
||||||
|
// by construction, not by preference: go-webauthn scopes allowCredentials to the user's
|
||||||
|
// bound passkeys (from WebAuthnCredentials), which is the only fit here because the
|
||||||
|
// enrolled credentials are not resident/discoverable and the challenge store is user-keyed
|
||||||
|
// (migration 0007) — discoverable ("usernameless") login would need resident-key
|
||||||
|
// enrollment plus a non-user-keyed challenge store, a future migration, so it is out of
|
||||||
|
// scope. It returns the {"publicKey": {...}} request options for navigator.credentials.get()
|
||||||
|
// and the opaque, marshaled SessionData the handler stashes and replays at finish. A user
|
||||||
|
// with no bound credential yields an error from go-webauthn (nothing to assert); the caller
|
||||||
|
// treats that as "offer the email-OTP fallback instead", never as a server fault.
|
||||||
|
func (v *Verifier) BeginLogin(user api.PasskeyUser) (json.RawMessage, []byte, error) {
|
||||||
|
assertion, session, err := v.wa.BeginLogin(webauthnUser{u: user})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// CredentialAssertion marshals to {"publicKey": {...}} (its Response field carries the
|
||||||
|
// `publicKey` json tag), exactly the document the browser hands to navigator.credentials.get().
|
||||||
|
options, err := json.Marshal(assertion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// As with registration, we stash the marshaled SessionData verbatim and let the
|
||||||
|
// challenge row's TTL be the sole authority on liveness (no expiry inside SessionData).
|
||||||
|
sessionData, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return options, sessionData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishLogin verifies the browser's assertion against the stashed SessionData and reports
|
||||||
|
// which of the user's credentials signed and the signature counter the authenticator
|
||||||
|
// reported. go-webauthn checks the challenge, RP id, and origin against server-held values,
|
||||||
|
// that the asserted credential id is one the user actually holds (it returns
|
||||||
|
// protocol.ErrorUnknownCredential otherwise), and the signature against the stored COSE
|
||||||
|
// public key. It does NOT decide clone/regression policy here: the returned SignCount is
|
||||||
|
// the raw ceremony fact, and the handler — which holds the previously-stored counter —
|
||||||
|
// decides whether a non-increase is a cloned-authenticator signal. The verified credential
|
||||||
|
// id is returned base64url so the handler can look up the exact row to update.
|
||||||
|
func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) {
|
||||||
|
var session webauthn.SessionData
|
||||||
|
if err := json.Unmarshal(sessionData, &session); err != nil {
|
||||||
|
return api.VerifiedAssertion{}, err
|
||||||
|
}
|
||||||
|
parsed, err := protocol.ParseCredentialRequestResponseBody(assertion)
|
||||||
|
if err != nil {
|
||||||
|
return api.VerifiedAssertion{}, err
|
||||||
|
}
|
||||||
|
cred, err := v.wa.ValidateLogin(webauthnUser{u: user}, session, parsed)
|
||||||
|
if err != nil {
|
||||||
|
return api.VerifiedAssertion{}, err
|
||||||
|
}
|
||||||
|
return api.VerifiedAssertion{
|
||||||
|
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
|
||||||
|
SignCount: cred.Authenticator.SignCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// excludeDescriptors turns the principal's already-bound passkeys into the
|
// excludeDescriptors turns the principal's already-bound passkeys into the
|
||||||
// excludeCredentials list for a creation ceremony. A stored credential id that does not
|
// excludeCredentials list for a creation ceremony. A stored credential id that does not
|
||||||
// decode as base64url is skipped rather than aborting the whole ceremony — a single
|
// decode as base64url is skipped rather than aborting the whole ceremony — a single
|
||||||
@@ -171,8 +235,14 @@ func (w webauthnUser) WebAuthnName() string { return w.u.Name }
|
|||||||
func (w webauthnUser) WebAuthnDisplayName() string { return w.u.DisplayName }
|
func (w webauthnUser) WebAuthnDisplayName() string { return w.u.DisplayName }
|
||||||
|
|
||||||
// WebAuthnCredentials returns the principal's bound passkeys as webauthn.Credentials.
|
// WebAuthnCredentials returns the principal's bound passkeys as webauthn.Credentials.
|
||||||
// Enrollment only needs the credential ids (for identity/exclusion bookkeeping), so only
|
// Enrollment needs only the credential ids (for identity/exclusion bookkeeping); login
|
||||||
// the id is populated; a row whose id does not decode is skipped.
|
// (assertion) validation additionally needs the stored COSE public key (to verify the
|
||||||
|
// signature) and the last-seen signature counter (for clone detection), so both are
|
||||||
|
// populated when present. Filling them is backward-compatible with enrollment, which
|
||||||
|
// simply ignores the extra fields. A row whose id does not decode is skipped entirely; a
|
||||||
|
// row whose public key does not decode is still surfaced (so it counts for exclusion) but
|
||||||
|
// with a nil key, so an assertion against it cannot verify — it fails closed rather than
|
||||||
|
// silently accepting.
|
||||||
func (w webauthnUser) WebAuthnCredentials() []webauthn.Credential {
|
func (w webauthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||||
out := make([]webauthn.Credential, 0, len(w.u.Credentials))
|
out := make([]webauthn.Credential, 0, len(w.u.Credentials))
|
||||||
for _, c := range w.u.Credentials {
|
for _, c := range w.u.Credentials {
|
||||||
@@ -180,7 +250,12 @@ func (w webauthnUser) WebAuthnCredentials() []webauthn.Credential {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, webauthn.Credential{ID: id})
|
cred := webauthn.Credential{ID: id}
|
||||||
|
if key, err := base64.StdEncoding.DecodeString(c.PublicKey); err == nil {
|
||||||
|
cred.PublicKey = key
|
||||||
|
}
|
||||||
|
cred.Authenticator.SignCount = c.SignCount
|
||||||
|
out = append(out, cred)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package passkey
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -157,14 +158,7 @@ func TestBeginExcludesBoundCredentials(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseAttestationOptions: %v", err)
|
t.Fatalf("ParseAttestationOptions: %v", err)
|
||||||
}
|
}
|
||||||
found := false
|
if !slices.Contains(attestationOpts.ExcludeCredentials, existingID) {
|
||||||
for _, ex := range attestationOpts.ExcludeCredentials {
|
|
||||||
if ex == existingID {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Errorf("excludeCredentials = %v, want it to contain %q", attestationOpts.ExcludeCredentials, existingID)
|
t.Errorf("excludeCredentials = %v, want it to contain %q", attestationOpts.ExcludeCredentials, existingID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,3 +170,131 @@ func TestNewRejectsEmptyRPID(t *testing.T) {
|
|||||||
t.Fatal("New accepted an empty RP id; want an error")
|
t.Fatal("New accepted an empty RP id; want an error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrollCredential runs a real credential-creation ceremony and returns the verified
|
||||||
|
// credential as the persist-ready stored view a later login validates against. Starting
|
||||||
|
// the login tests from a GENUINE COSE public key (not a hand-built one) is what makes them
|
||||||
|
// exercise WebAuthnCredentials()' base64 decode path — the exact spot an adapter silently
|
||||||
|
// breaks.
|
||||||
|
func enrollCredential(t *testing.T, v *Verifier, rp virtualwebauthn.RelyingParty, auth virtualwebauthn.Authenticator, cred virtualwebauthn.Credential) api.PasskeyCredential {
|
||||||
|
t.Helper()
|
||||||
|
options, sessionData, err := v.BeginRegistration(testUser())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginRegistration: %v", err)
|
||||||
|
}
|
||||||
|
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAttestationOptions: %v", err)
|
||||||
|
}
|
||||||
|
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *attestationOpts)
|
||||||
|
vc, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FinishRegistration: %v", err)
|
||||||
|
}
|
||||||
|
return api.PasskeyCredential{CredentialID: vc.CredentialID, PublicKey: vc.PublicKey, SignCount: vc.SignCount}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRoundTrip is the PARITY check for the assertion (login) half, deliberately
|
||||||
|
// chained onto a REAL enrollment so the login validates against a genuine COSE public key.
|
||||||
|
// A real go-webauthn RP (through our adapter) enrolls a virtual authenticator's credential;
|
||||||
|
// the verified public key + credential id are fed back as the user's STORED credential into
|
||||||
|
// BeginLogin → the virtual authenticator signs an assertion → FinishLogin verifies it. This
|
||||||
|
// exercises exactly the path a hand-built credential would skip: WebAuthnCredentials()
|
||||||
|
// decoding the base64 COSE key so ValidateLogin can check the signature against it. The
|
||||||
|
// authenticator's counter is advanced before the assertion so the test also proves
|
||||||
|
// FinishLogin surfaces the real signature counter rather than a hardcoded 0.
|
||||||
|
func TestLoginRoundTrip(t *testing.T) {
|
||||||
|
v := newTestVerifier(t)
|
||||||
|
rp := virtualRP()
|
||||||
|
authenticator := virtualwebauthn.NewAuthenticator()
|
||||||
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
|
||||||
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
||||||
|
|
||||||
|
// The authenticator reports an advanced counter; a fresh enrollment stored 0, so this
|
||||||
|
// is a strict increase (no clone warning) and must survive through to VerifiedAssertion.
|
||||||
|
cred.Counter = 7
|
||||||
|
|
||||||
|
options, sessionData, err := v.BeginLogin(testUser(stored))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginLogin: %v", err)
|
||||||
|
}
|
||||||
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
|
||||||
|
}
|
||||||
|
if assertionOpts.RelyingPartyID != testRPID {
|
||||||
|
t.Fatalf("options RP id = %q, want %q", assertionOpts.RelyingPartyID, testRPID)
|
||||||
|
}
|
||||||
|
// The bound credential must be offered as an allowCredentials entry — username-first,
|
||||||
|
// the ceremony names which credentials the known user may assert.
|
||||||
|
wantID := base64.RawURLEncoding.EncodeToString(cred.ID)
|
||||||
|
if !slices.Contains(assertionOpts.AllowCredentials, wantID) {
|
||||||
|
t.Fatalf("allowCredentials = %v, want it to contain %q", assertionOpts.AllowCredentials, wantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
||||||
|
va, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FinishLogin: %v", err)
|
||||||
|
}
|
||||||
|
if va.CredentialID != stored.CredentialID {
|
||||||
|
t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID)
|
||||||
|
}
|
||||||
|
if va.SignCount != 7 {
|
||||||
|
t.Errorf("SignCount = %d, want 7 (the authenticator's advanced counter)", va.SignCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginOriginMismatchRejected proves FinishLogin actually checks the origin: an
|
||||||
|
// assertion signed for an origin the RP does not permit must fail. Without this guard the
|
||||||
|
// round-trip test would be hollow — it would accept a signature from anywhere.
|
||||||
|
func TestLoginOriginMismatchRejected(t *testing.T) {
|
||||||
|
v := newTestVerifier(t)
|
||||||
|
rp := virtualRP()
|
||||||
|
authenticator := virtualwebauthn.NewAuthenticator()
|
||||||
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
||||||
|
|
||||||
|
options, sessionData, err := v.BeginLogin(testUser(stored))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginLogin: %v", err)
|
||||||
|
}
|
||||||
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
||||||
|
}
|
||||||
|
// Sign the assertion for a foreign origin the verifier does not permit.
|
||||||
|
evil := virtualwebauthn.RelyingParty{ID: testRPID, Name: testRPName, Origin: "https://evil.example.net"}
|
||||||
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(evil, authenticator, cred, *assertionOpts)
|
||||||
|
if _, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)); err == nil {
|
||||||
|
t.Fatal("FinishLogin accepted an assertion signed for a foreign origin; want rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginUnknownCredentialRejected proves the credential-ownership binding: a valid
|
||||||
|
// signature over the right challenge is NOT enough — it must come from one of the user's
|
||||||
|
// own bound credentials. The user's stored credential is A, but the assertion is signed by
|
||||||
|
// a different, never-bound credential B; go-webauthn must reject it (B is not in the
|
||||||
|
// challenge's allowCredentials, nor among the user's credentials).
|
||||||
|
func TestLoginUnknownCredentialRejected(t *testing.T) {
|
||||||
|
v := newTestVerifier(t)
|
||||||
|
rp := virtualRP()
|
||||||
|
authenticator := virtualwebauthn.NewAuthenticator()
|
||||||
|
credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
stored := enrollCredential(t, v, rp, authenticator, credA)
|
||||||
|
|
||||||
|
options, sessionData, err := v.BeginLogin(testUser(stored))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginLogin: %v", err)
|
||||||
|
}
|
||||||
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
||||||
|
}
|
||||||
|
credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts)
|
||||||
|
if _, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)); err == nil {
|
||||||
|
t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user