diff --git a/README.md b/README.md index 726364a..bf48598 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,11 @@ docker compose up -d 在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。 -3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。 +3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。 -4. 以后在登录页输入用户名,点击“使用通行证密钥登录”并解锁设备。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。 +4. 以后在登录页直接点击“选择通行证密钥登录”,由设备选择账号并解锁,**无需先填写用户名**。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。 + + 旧版本绑定时只“优先”请求可发现凭据,现有密钥是否能直接选账号取决于当时的设备,不能按绑定版本统一判定或批量删除。若设备不显示某把旧密钥,请使用密码登录,在个人资料页删除该密钥并重新绑定;确认新密钥与密码恢复途径可用后再清理其他旧密钥。登录页不提供用户名密钥回退。 **入口未显示时,按顺序检查:** diff --git a/go-backend/internal/http/handler/passkey.go b/go-backend/internal/http/handler/passkey.go index 1d12ddf..16038ed 100644 --- a/go-backend/internal/http/handler/passkey.go +++ b/go-backend/internal/http/handler/passkey.go @@ -60,7 +60,7 @@ func passkeyConfig() (*webauthn.WebAuthn, string) { RPID: host, RPDisplayName: "FLVX", RPOrigins: []string{origin}, - AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementPreferred}, + AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementRequired}, Timeouts: webauthn.TimeoutsConfig{ Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL}, Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL}, @@ -112,6 +112,17 @@ func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (we return c.session, true } +func (h *Handler) takePasskeyLoginCeremony(id, origin string) (passkeyCeremony, bool) { + h.passkeyMu.Lock() + c, ok := h.passkeyPending[id] + delete(h.passkeyPending, id) + h.passkeyMu.Unlock() + if !ok || c.kind != "login-discoverable" || c.origin != origin || time.Now().After(c.session.Expires) { + return passkeyCeremony{}, false + } + return c, true +} + func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) { user, err := h.repo.GetUserByID(userID) if err != nil || user == nil || user.Status != 1 { @@ -178,7 +189,7 @@ func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥")) return } - options, session, err := wa.BeginRegistration(u) + options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) if err != nil { response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战")) return @@ -278,26 +289,16 @@ func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` } - if !passkeyBody(r, &req) { + if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } - user, err := h.repo.GetUserByUsername(strings.TrimSpace(req.Username)) - if err != nil || user == nil || user.Status != 1 { - response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录")) - return - } - u, err := h.loadPasskeyUser(user.ID) - if err != nil || len(u.credentials) == 0 { - response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录")) - return - } - options, session, err := wa.BeginLogin(u, webauthn.WithUserVerification(protocol.VerificationRequired)) + options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) if err != nil { response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战")) return } - id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: user.ID, kind: "login", origin: origin, session: *session}) + id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session}) if !ok { response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战")) return @@ -320,22 +321,35 @@ func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } - // Login sessions carry the user ID; the caller cannot choose a different account at finish. - h.passkeyMu.Lock() - pending := h.passkeyPending[req.SessionID] - h.passkeyMu.Unlock() - session, ok := h.takePasskeyCeremony(req.SessionID, "login", origin, pending.userID) + pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin) if !ok { response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期")) return } - u, err := h.loadPasskeyUser(pending.userID) - if err != nil { - response.WriteJSON(w, response.ErrDefault("账号不可用")) - return + resolved, credential, err := wa.FinishPasskeyLogin(func(rawID, userHandle []byte) (webauthn.User, error) { + // The credential ID determines ownership. A supplied userHandle cannot select another account. + if len(rawID) == 0 || len(userHandle) != 8 { + return nil, errInvalidPasskey + } + row, lookupErr := h.repo.GetPasskeyByID(base64.RawURLEncoding.EncodeToString(rawID)) + if lookupErr != nil || row == nil { + return nil, errInvalidPasskey + } + owner, loadErr := h.loadPasskeyUser(row.UserID) + if loadErr != nil || !bytes.Equal(userHandle, owner.WebAuthnID()) { + return nil, errInvalidPasskey + } + return owner, nil + }, pending.session, credentialRequest(r, req.Credential)) + var u passkeyUser + if err == nil { + var valid bool + u, valid = resolved.(passkeyUser) + if !valid { + err = errInvalidPasskey + } } - credential, err := wa.FinishLogin(u, session, credentialRequest(r, req.Credential)) - if err != nil || credential.Authenticator.CloneWarning { + if err != nil || credential == nil || credential.Authenticator.CloneWarning { response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败")) return } diff --git a/go-backend/internal/http/handler/passkey_test.go b/go-backend/internal/http/handler/passkey_test.go index 8d27cdd..c0ac5fc 100644 --- a/go-backend/internal/http/handler/passkey_test.go +++ b/go-backend/internal/http/handler/passkey_test.go @@ -9,21 +9,48 @@ import ( func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) { h := &Handler{passkeyPending: make(map[string]passkeyCeremony)} - id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}}) + id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}}) if !ok { t.Fatal("could not create challenge") } - if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); !ok { + if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); !ok { t.Fatal("valid challenge was rejected") } - if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok { + if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok { t.Fatal("challenge was reusable") } - id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}}) + id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}}) if !ok { t.Fatal("could not create expired challenge") } - if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok { + if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok { t.Fatal("expired challenge was accepted") } + id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}}) + if !ok { + t.Fatal("could not create discoverable challenge") + } + if _, ok := h.takePasskeyLoginCeremony(id, "https://wrong.example.test"); ok { + t.Fatal("wrong origin was accepted") + } + if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok { + t.Fatal("failed discoverable challenge was reusable") + } + id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}}) + if !ok { + t.Fatal("could not create discoverable challenge") + } + if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); !ok { + t.Fatal("valid discoverable challenge was rejected") + } + if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok { + t.Fatal("discoverable challenge was reusable") + } + id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}}) + if !ok { + t.Fatal("could not create expired discoverable challenge") + } + if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok { + t.Fatal("expired discoverable challenge was accepted") + } } diff --git a/go-backend/internal/store/repo/repository_passkey.go b/go-backend/internal/store/repo/repository_passkey.go index f475dd2..fd45f68 100644 --- a/go-backend/internal/store/repo/repository_passkey.go +++ b/go-backend/internal/store/repo/repository_passkey.go @@ -22,6 +22,17 @@ func (r *Repository) GetPasskey(userID int64, id string) (*model.Passkey, error) return &key, err } +// Credential IDs are unique across all users. Resolve the owner from the stored +// credential before checking the untrusted user handle in a discoverable login. +func (r *Repository) GetPasskeyByID(id string) (*model.Passkey, error) { + var key model.Passkey + err := r.db.Where("id = ?", id).Take(&key).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &key, err +} + func (r *Repository) CreatePasskey(key *model.Passkey) error { return r.db.Create(key).Error } diff --git a/go-backend/tests/contract/passkey_contract_test.go b/go-backend/tests/contract/passkey_contract_test.go index a73a936..a829a99 100644 --- a/go-backend/tests/contract/passkey_contract_test.go +++ b/go-backend/tests/contract/passkey_contract_test.go @@ -62,7 +62,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T) if status["enabled"] != false { t.Fatalf("invalid origin enabled passkeys: %v", status) } - if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-disabled"}); result.Code == 0 { + if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}); result.Code == 0 { t.Fatal("login begin succeeded with invalid origin") } if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-disabled", "password": "test-password"}); result.Code != 0 { @@ -70,7 +70,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T) } } -func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) { +func TestPasskeyRegistrationAndOwnership(t *testing.T) { t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin) router, r := setupContractRouter(t, "passkey-test-secret") seedLegacyUser(t, r, 9302, "passkey-alice", "alice-password") @@ -90,6 +90,10 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) { } // The failed cross-user attempt consumes the challenge. begin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"})) + selection := begin["options"].(map[string]interface{})["publicKey"].(map[string]interface{})["authenticatorSelection"].(map[string]interface{}) + if selection["residentKey"] != "required" || selection["requireResidentKey"] != true { + t.Fatalf("registration did not require a discoverable credential: %v", selection) + } privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) @@ -113,45 +117,148 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) { if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", bobToken, map[string]string{"id": keyID, "password": "bob-password"}); result.Code == 0 { t.Fatal("Bob deleted Alice's credential") } - - loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"})) - bad := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), "https://wrong.example.test", "panel.example.test", true, 1) - if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": bad}); result.Code == 0 { - t.Fatal("wrong origin was accepted") - } - loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"})) - assertion := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1) - loginBody := map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion} - loginResult := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody) - if passkeyData(t, loginResult)["token"] == "" { - t.Fatal("passkey login returned no JWT") - } - if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody); result.Code == 0 { - t.Fatal("login challenge was reusable") - } - loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"})) - badRP := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "wrong.example.test", true, 2) - if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": badRP}); result.Code == 0 { - t.Fatal("wrong RP ID was accepted") - } - loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"})) - noUV := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", false, 2) - if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": noUV}); result.Code == 0 { - t.Fatal("assertion without user verification was accepted") - } - loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"})) - reusedCounter := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1) - if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": reusedCounter}); result.Code == 0 { - t.Fatal("reused nonzero signature counter was accepted") - } if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "wrong"}); result.Code == 0 { t.Fatal("delete did not require password re-verification") } if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "alice-password"}); result.Code != 0 { t.Fatalf("delete: %s", result.Msg) } - if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}); result.Code == 0 { - t.Fatal("deleted credential could still start login") + loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{})) + aliceHandle := make([]byte, 8) + binary.BigEndian.PutUint64(aliceHandle, 9302) + assertion := makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1, aliceHandle) + if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}); result.Code == 0 { + t.Fatal("deleted credential could still complete login") + } + // Deletion must not prevent the same account from binding a new key. + rebind := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"})) + newID := make([]byte, 32) + if _, err := rand.Read(newID); err != nil { + t.Fatal(err) + } + newResponse := makeRegistrationResponse(t, privateKey, newID, passkeyChallenge(t, rebind), passkeyTestOrigin) + if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": rebind["sessionId"], "credential": newResponse}); result.Code != 0 { + t.Fatalf("rebind after delete: %s", result.Msg) + } + if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-alice", "password": "alice-password"}); result.Code != 0 { + t.Fatalf("password login regressed after enabling passkeys: %s", result.Msg) + } +} + +func TestPasskeyDiscoverableLoginAndOwnership(t *testing.T) { + t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin) + router, r := setupContractRouter(t, "passkey-test-secret") + seedLegacyUser(t, r, 9311, "discover-alice", "alice-password") + seedLegacyUser(t, r, 9312, "discover-bob", "bob-password") + aliceToken, _ := auth.GenerateToken(9311, "discover-alice", 1, "passkey-test-secret") + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + credentialID := make([]byte, 32) + if _, err := rand.Read(credentialID); err != nil { + t.Fatal(err) + } + register := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"})) + registration := makeRegistrationResponse(t, privateKey, credentialID, passkeyChallenge(t, register), passkeyTestOrigin) + if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": register["sessionId"], "credential": registration}); result.Code != 0 { + t.Fatalf("register: %s", result.Msg) + } + aliceHandle := make([]byte, 8) + binary.BigEndian.PutUint64(aliceHandle, 9311) + bobHandle := make([]byte, 8) + binary.BigEndian.PutUint64(bobHandle, 9312) + begin := func(body interface{}) map[string]interface{} { + t.Helper() + return passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", body)) + } + if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "discover-alice"}); result.Code == 0 { + t.Fatal("username-based passkey fallback was accepted") + } + finish := func(session map[string]interface{}, assertion map[string]interface{}) response.R { + t.Helper() + return passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": session["sessionId"], "credential": assertion}) + } + assertionFor := func(session map[string]interface{}, handle []byte, origin, rpID string, uv bool, count uint32) map[string]interface{} { + t.Helper() + return makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, session), origin, rpID, uv, count, handle) + } + + // No username and no allowCredentials let the authenticator offer an account. + session := begin(map[string]string{}) + options := session["options"].(map[string]interface{})["publicKey"].(map[string]interface{}) + if list, exists := options["allowCredentials"]; exists && list != nil { + t.Fatalf("discoverable login unexpectedly restricted credentials: %v", list) + } + if options["userVerification"] != "required" { + t.Fatalf("discoverable login did not require UV: %v", options) + } + wrongHandle := assertionFor(session, bobHandle, passkeyTestOrigin, "panel.example.test", true, 1) + if result := finish(session, wrongHandle); result.Code == 0 { + t.Fatal("Bob's user handle selected Alice's credential") + } + if result := finish(session, wrongHandle); result.Code == 0 { + t.Fatal("failed discoverable challenge was reusable") + } + + session = begin(map[string]string{}) + // Older preferred resident-key registrations may have no discoverable userHandle. + missingHandle := assertionFor(session, nil, passkeyTestOrigin, "panel.example.test", true, 1) + if result := finish(session, missingHandle); result.Code == 0 { + t.Fatal("discoverable login accepted a missing user handle") + } + session = begin(map[string]string{}) + unknownID := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1) + unknownID["id"] = base64.RawURLEncoding.EncodeToString([]byte("unknown-credential")) + unknownID["rawId"] = unknownID["id"] + if result := finish(session, unknownID); result.Code == 0 { + t.Fatal("unknown credential ID selected Alice's account") + } + + for _, test := range []struct { + name string + origin string + rpID string + uv bool + }{ + {"origin", "https://wrong.example.test", "panel.example.test", true}, + {"RP ID", passkeyTestOrigin, "wrong.example.test", true}, + {"user verification", passkeyTestOrigin, "panel.example.test", false}, + } { + session := begin(map[string]string{}) + assertion := assertionFor(session, aliceHandle, test.origin, test.rpID, test.uv, 1) + if result := finish(session, assertion); result.Code == 0 { + t.Fatalf("invalid discoverable assertion was accepted: %s", test.name) + } + } + session = begin(map[string]string{}) + badSignature := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1) + badSignature["response"].(map[string]interface{})["signature"] = base64.RawURLEncoding.EncodeToString([]byte("invalid-signature")) + if result := finish(session, badSignature); result.Code == 0 { + t.Fatal("invalid signature was accepted") + } + + session = begin(map[string]string{}) + valid := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1) + if got := passkeyData(t, finish(session, valid))["name"]; got != "discover-alice" { + t.Fatalf("discoverable login selected the wrong account: %v", got) + } + if result := finish(session, valid); result.Code == 0 { + t.Fatal("discoverable assertion was replayed") + } + session = begin(map[string]string{}) + reusedCounter := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1) + if result := finish(session, reusedCounter); result.Code == 0 { + t.Fatal("discoverable login accepted a reused nonzero signature counter") + } + + if err := r.DB().Exec("UPDATE user SET status = 0 WHERE id = ?", 9311).Error; err != nil { + t.Fatal(err) + } + session = begin(map[string]string{}) + disabled := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 2) + if result := finish(session, disabled); result.Code == 0 { + t.Fatal("disabled account logged in with a discoverable credential") } } @@ -180,6 +287,10 @@ func makeRegistrationResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []b } func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32) map[string]interface{} { + return makeAssertionResponseWithHandle(t, privateKey, id, challenge, origin, rpID, verified, count, nil) +} + +func makeAssertionResponseWithHandle(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32, handle []byte) map[string]interface{} { t.Helper() rpHash := sha256.Sum256([]byte(rpID)) authData := append([]byte{}, rpHash[:]...) @@ -197,5 +308,9 @@ func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte if err != nil { t.Fatal(err) } - return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": nil}} + var userHandle interface{} + if handle != nil { + userHandle = base64.RawURLEncoding.EncodeToString(handle) + } + return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": userHandle}} } diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 8abd148..56a2267 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -84,8 +84,8 @@ export interface PasskeyItem { export const getPasskeyStatus = () => Network.post<{ enabled: boolean }>("/user/passkey/status"); -export const beginPasskeyLogin = (username: string) => - Network.post("/user/passkey/login/begin", { username }); +export const beginPasskeyLogin = () => + Network.post("/user/passkey/login/begin", {}); export const finishPasskeyLogin = (sessionId: string, credential: unknown) => Network.post("/user/passkey/login/finish", { sessionId, diff --git a/vite-frontend/src/pages/index.tsx b/vite-frontend/src/pages/index.tsx index 250ec0e..a02b6f8 100644 --- a/vite-frontend/src/pages/index.tsx +++ b/vite-frontend/src/pages/index.tsx @@ -55,14 +55,9 @@ export default function IndexPage() { }, []); const handlePasskeyLogin = async () => { - if (!form.username.trim()) { - toast.error("请输入用户名"); - - return; - } setLoading(true); try { - const begin = await beginPasskeyLogin(form.username.trim()); + const begin = await beginPasskeyLogin(); if (begin.code !== 0) { toast.error(begin.msg || "无法使用通行证密钥登录"); @@ -282,9 +277,9 @@ export default function IndexPage() { className="h-12 rounded-xl" disabled={loading} variant="bordered" - onPress={handlePasskeyLogin} + onPress={() => void handlePasskeyLogin()} > - 使用通行证密钥登录 + 选择通行证密钥登录 )}