From 3e7efb3c880e15fa0782240fc4596587d5a66f9c Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:00:51 +0800 Subject: [PATCH] =?UTF-8?q?fix=20passkey=20server/internal/passkey/passkey?= =?UTF-8?q?.go=20=20=E7=9A=84=20takeSession=EF=BC=8C=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8F=AA=E5=9C=A8=E6=98=BE=E5=BC=8F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=BA=86=20Expires=20=E6=97=B6=E6=89=8D=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=88=E4=B8=8E=20go-webauthn=20=E5=BA=93=E5=86=85?= =?UTF-8?q?=E9=83=A8=20!IsZero()=20=E7=9A=84=E6=A3=80=E6=9F=A5=E4=B8=80?= =?UTF-8?q?=E8=87=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/passkey/passkey.go | 5 ++- server/internal/passkey/passkey_test.go | 56 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 server/internal/passkey/passkey_test.go diff --git a/server/internal/passkey/passkey.go b/server/internal/passkey/passkey.go index 7daf004..2479e22 100644 --- a/server/internal/passkey/passkey.go +++ b/server/internal/passkey/passkey.go @@ -235,7 +235,10 @@ func (s *Service) takeSession(challenge string) (webauthn.SessionData, bool) { delete(s.sessions, challenge) } s.mu.Unlock() - if ok && time.Now().After(sess.Expires) { + // Expires 可能为零值:go-webauthn 默认 Enforce=false 不设过期时间。 + // 零值时间恒早于 now,直接 After 会把每个 challenge 都判为过期, + // 与库内部一致,仅当显式设置了过期时间才做校验。 + if ok && !sess.Expires.IsZero() && time.Now().After(sess.Expires) { return webauthn.SessionData{}, false } return sess, ok diff --git a/server/internal/passkey/passkey_test.go b/server/internal/passkey/passkey_test.go new file mode 100644 index 0000000..d49c802 --- /dev/null +++ b/server/internal/passkey/passkey_test.go @@ -0,0 +1,56 @@ +package passkey + +import ( + "testing" + + "github.com/glebarez/sqlite" + "github.com/openteam/server/internal/store" + "gorm.io/gorm" +) + +func newTestService(t *testing.T) *Service { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&store.User{}, &store.Passkey{}); err != nil { + t.Fatal(err) + } + s, err := New(db, Config{RPID: "localhost", Origin: "http://localhost:8080", Name: "openteam test"}) + if err != nil { + t.Fatal(err) + } + return s +} + +// TestChallengeSessionRoundTrip 验证 begin 产生的 challenge 能被 complete 取回。 +// go-webauthn 默认 Enforce=false,SessionData.Expires 为零值;takeSession 的 +// 过期判断必须跳过零值时间,否则每个 challenge 都被当成已过期(线上必现 +// "challenge 已过期或不存在")。 +func TestChallengeSessionRoundTrip(t *testing.T) { + s := newTestService(t) + u := &store.User{Username: "alice", Email: "alice@example.com", Role: "user", Status: "active"} + if err := s.db.Create(u).Error; err != nil { + t.Fatal(err) + } + + creation, err := s.BeginRegistration(u) + if err != nil { + t.Fatal(err) + } + challenge := creation.Response.Challenge.String() // 与前端回传一致的 base64url + + sess, ok := s.takeSession(challenge) + if !ok { + t.Fatal("takeSession 返回 false:challenge 被误判为已过期(Expires 零值 bug)") + } + if sess.Challenge != challenge { + t.Fatalf("session challenge 不匹配: %q != %q", sess.Challenge, challenge) + } + + // challenge 一次性消费 + if _, ok := s.takeSession(challenge); ok { + t.Fatal("takeSession 应一次性消费 challenge,二次取应失败") + } +}