fix(passkey): 断言时按凭据校验 UV,并把存储的 BE/BS 标志交给校验器,云同步 passkey 可以登录 (#9)

This commit is contained in:
Lemon-miaow committed 2026-09-26 07:12:20 +08:00
1 parent 6a4c30b3f1
commit 9ab7b27a30
13 files changed
+319 -96

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
// uvCases are the ways an assertion can lack user verification (#9): the credential
// was bound presence-only (a pre-0009 row, or a future UV=preferred enrollment), this
// assertion itself did not verify the user, or it names a credential the account does
// not hold. Each must be refused at every door that grants or confirms a session.
var uvCases = []struct {
name string
storedUV bool
assertUV bool
assertCred string
}{
{"credential bound without UV", false, true, ""},
{"assertion without UV", true, false, ""},
{"credential not bound to the account", true, true, "cred-elsewhere"},
}
func TestPasskeyLoginRefusesUnverifiedUser(t *testing.T) {
for _, tc := range uvCases {
t.Run(tc.name, func(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
row := repo.passkeyCreds["row1"]
row.UserVerified = tc.storedUV
repo.passkeyCreds["row1"] = row
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
if tc.assertCred != "" {
v.assertion.CredentialID = tc.assertCred
}
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "YXNzZXJ0"), jsonHeader)
assertUVRefused(t, w, repo, "player")
})
}
}
func TestPasskeyDiscoverableLoginRefusesUnverifiedUser(t *testing.T) {
for _, tc := range uvCases {
t.Run(tc.name, func(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
row := repo.passkeyCreds["row1"]
row.UserVerified = tc.storedUV
repo.passkeyCreds["row1"] = row
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
if tc.assertCred != "" {
v.assertion.CredentialID = tc.assertCred
}
eh := api.ExternalHandler()
loginID := beginDiscoverableLogin(t, eh)
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
assertUVRefused(t, w, repo, "player")
})
}
}
func TestReauthByPasskeyRefusesUnverifiedUser(t *testing.T) {
for _, tc := range uvCases {
t.Run(tc.name, func(t *testing.T) {
f := reauthFixture(t)
pv := f.api.Passkey.(*fakePasskeyVerifier)
f.repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1",
UserVerified: tc.storedUV, CreatedAt: frozenNow}
pv.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
if tc.assertCred != "" {
pv.assertion.CredentialID = tc.assertCred
}
if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
f.repo.audits = nil
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"cred-1"}}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("finish = %d (%s), want 400 passkey_login_invalid", w.Code, w.Body.String())
}
if !f.reauthAt(laptopTok).IsZero() {
t.Fatal("an unverified assertion marked the session reauthenticated")
}
if got := f.repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
t.Errorf("refusal advanced the credential: SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
}
if n := len(f.repo.audits); n != 1 || f.repo.audits[0].Action != "auth.passkey_uv_rejected" {
t.Fatalf("want exactly 1 auth.passkey_uv_rejected audit, got %+v", f.repo.audits)
}
})
}
}
// assertUVRefused checks a sign-in door's refusal: the opaque envelope every finish
// failure shares, no session, the stored credential untouched, and one distinct audit
// under the account.
func assertUVRefused(t *testing.T, rec *httptest.ResponseRecorder, repo *fakeRepo, actor string) {
t.Helper()
if rec.Code != http.StatusBadRequest || decodeErr(t, rec) != "passkey_login_invalid" {
t.Fatalf("finish = %d (%s), want 400 passkey_login_invalid", rec.Code, rec.Body.String())
}
if len(repo.sessions) != 0 || len(rec.Result().Cookies()) != 0 {
t.Fatalf("refusal minted a session: sessions=%d cookies=%v", len(repo.sessions), rec.Result().Cookies())
}
if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
t.Errorf("refusal advanced the credential: SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
}
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_uv_rejected" || repo.audits[0].Actor != actor {
t.Fatalf("want exactly 1 auth.passkey_uv_rejected audit by %s, got %+v", actor, repo.audits)
}
}