Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,11 @@ Examples:
If you are not one of the listed assistants, do not add a `Co-authored-by` trailer.

Instead, ask the human collaborator to provide the exact `Co-authored-by` trailer to use. Do not invent, infer, or generate one yourself.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The passkey is unrelated to the agent's instructions and should not be specifically mentioned.

Passkey 与 AGENT 指导无关,不应特别提示。

## Passkey Authentication

- The existing `User` record is the sole account owner for passwords, sessions, recovery methods, and passkeys. Do not create a parallel passkey account system.
- WebAuthn challenges are server-side, one-time, ceremony/user-bound, and expire after five minutes. Never return serialized `webauthn.SessionData` to clients.
- Passkey ceremonies require user verification and validate the exact configured RP ID and origin. Production deployments require an absolute HTTPS `site_url`.
- Persist the credential returned by a successful assertion before issuing a login token so signature counters, backup flags, and `last_used_at` cannot be lost.
- The in-memory challenge store supports the documented single-process deployment. Multi-replica deployments must provide a shared atomic challenge store before enabling passkeys.
61 changes: 54 additions & 7 deletions internal/authn/authn.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,73 @@
package authn

import (
"fmt"
"errors"
"net"
"net/url"
"strings"

"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)

func NewAuthnInstance(c *gin.Context) (*webauthn.WebAuthn, error) {
siteUrl, err := url.Parse(common.GetApiUrl(c.Request.Context()))
rawURL := common.GetApiUrl(c.Request.Context())
siteURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
configuredURL, configuredErr := url.Parse(conf.Conf.SiteURL)
configuredAbsolute := configuredErr == nil &&
(configuredURL.Scheme == "http" || configuredURL.Scheme == "https") &&
configuredURL.Hostname() != ""
if !configuredAbsolute && !isLocalHostname(siteURL.Hostname()) {
return nil, errors.New("passkeys require an absolute site_url in production")
}
return NewAuthnInstanceForURL(rawURL, setting.GetStr(conf.SiteTitle))
}

func NewAuthnInstanceForURL(rawURL, displayName string) (*webauthn.WebAuthn, error) {
siteURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if siteURL.Hostname() == "" || (siteURL.Scheme != "http" && siteURL.Scheme != "https") {
return nil, errors.New("passkeys require an absolute http(s) site_url")
}
if siteURL.Scheme != "https" && !isLocalHostname(siteURL.Hostname()) {
return nil, errors.New("passkeys require https outside localhost")
}
origin := siteURL.Scheme + "://" + siteURL.Host
return webauthn.New(&webauthn.Config{
RPDisplayName: setting.GetStr(conf.SiteTitle),
RPID: siteUrl.Hostname(),
//RPOrigin: siteUrl.String(),
RPOrigins: []string{fmt.Sprintf("%s://%s", siteUrl.Scheme, siteUrl.Host)},
// RPOrigin: "http://localhost:5173"
RPDisplayName: displayName,
RPID: siteURL.Hostname(),
RPOrigins: []string{origin},
RPTopOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
},
RPTopOriginVerificationMode: protocol.TopOriginExplicitVerificationMode,
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,
Timeout: ChallengeTTL,
},
Registration: webauthn.TimeoutConfig{
Enforce: true,
Timeout: ChallengeTTL,
},
},
})
}

func isLocalHostname(hostname string) bool {
if strings.EqualFold(hostname, "localhost") {
return true
}
ip := net.ParseIP(hostname)
return ip != nil && ip.IsLoopback()
}
67 changes: 67 additions & 0 deletions internal/authn/authn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package authn

import (
"crypto/sha256"
"testing"

"github.com/go-webauthn/webauthn/protocol"
)

func TestAuthnConfigRequiresUserVerificationAndPinsOrigin(t *testing.T) {
instance, err := NewAuthnInstanceForURL("https://files.example.com/openlist", "OpenList")
if err != nil {
t.Fatal(err)
}
if got := instance.Config.RPID; got != "files.example.com" {
t.Fatalf("RPID = %q, want files.example.com", got)
}
if got := instance.Config.RPOrigins; len(got) != 1 || got[0] != "https://files.example.com" {
t.Fatalf("RPOrigins = %#v, want exact deployment origin", got)
}
if got := instance.Config.AuthenticatorSelection.UserVerification; got != protocol.VerificationRequired {
t.Fatalf("UserVerification = %q, want required", got)
}
if !instance.Config.Timeouts.Login.Enforce || !instance.Config.Timeouts.Registration.Enforce {
t.Fatal("server-side WebAuthn timeouts are not enforced")
}
}

func TestAuthnConfigRequiresHTTPSOutsideLocalhost(t *testing.T) {
if _, err := NewAuthnInstanceForURL("http://files.example.com", "OpenList"); err == nil {
t.Fatal("non-local HTTP origin was accepted")
}
if _, err := NewAuthnInstanceForURL("http://localhost:5244", "OpenList"); err != nil {
t.Fatalf("localhost development origin rejected: %v", err)
}
}

func TestAuthnRejectsWrongOriginAndRPID(t *testing.T) {
instance, err := NewAuthnInstanceForURL("https://files.example.com", "OpenList")
if err != nil {
t.Fatal(err)
}
clientData := protocol.CollectedClientData{
Type: protocol.AssertCeremony,
Challenge: "challenge",
Origin: "https://attacker.example",
}
if err = clientData.Verify(
"challenge",
protocol.AssertCeremony,
instance.Config.RPOrigins,
nil,
instance.Config.RPTopOriginVerificationMode,
); err == nil {
t.Fatal("wrong origin was accepted")
}

wrongRP := sha256.Sum256([]byte("attacker.example"))
expectedRP := sha256.Sum256([]byte(instance.Config.RPID))
authenticatorData := protocol.AuthenticatorData{
RPIDHash: wrongRP[:],
Flags: protocol.FlagUserPresent | protocol.FlagUserVerified,
}
if err = authenticatorData.Verify(expectedRP[:], nil, true, true); err == nil {
t.Fatal("wrong RP ID hash was accepted")
}
}
99 changes: 99 additions & 0 deletions internal/authn/challenge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package authn

import (
"crypto/rand"
"encoding/base64"
"errors"
"sync"
"time"

"github.com/go-webauthn/webauthn/webauthn"
)

const (
ChallengeTTL = 5 * time.Minute
maxChallengeCount = 10000
)

var (
ErrChallengeInvalid = errors.New("passkey challenge is invalid, expired, or already used")
challenges = newChallengeStore()
)

type Ceremony string

const (
CeremonyLogin Ceremony = "login"
CeremonyRegistration Ceremony = "registration"
)

type Challenge struct {
Session webauthn.SessionData
Ceremony Ceremony
UserID uint
Name string
Expires time.Time
}

type challengeStore struct {
mu sync.Mutex
items map[string]Challenge
now func() time.Time
}

func newChallengeStore() *challengeStore {
return &challengeStore{items: make(map[string]Challenge), now: time.Now}
}

func (s *challengeStore) Put(challenge Challenge) (string, error) {
id := make([]byte, 32)
if _, err := rand.Read(id); err != nil {
return "", err
}
key := base64.RawURLEncoding.EncodeToString(id)
now := s.now()
challenge.Expires = now.Add(ChallengeTTL)

s.mu.Lock()
if len(s.items) >= maxChallengeCount {
oldestKey := ""
var oldestExpiry time.Time
for existingKey, existing := range s.items {
if !now.Before(existing.Expires) {
delete(s.items, existingKey)
continue
}
if oldestKey == "" || existing.Expires.Before(oldestExpiry) {
oldestKey = existingKey
oldestExpiry = existing.Expires
}
}
// ponytail: O(n) only at the 10k-entry ceiling; use an expiry heap if this becomes hot.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-standard ponytail comments, and using suboptimal solutions cannot be justified by the comments themselves.

不标准的ponytail注释,且使用次优方案并不能用注释为自己辩护。

if len(s.items) >= maxChallengeCount {
delete(s.items, oldestKey)
}
}
s.items[key] = challenge
s.mu.Unlock()
return key, nil
}

func (s *challengeStore) Consume(key string, ceremony Ceremony, userID uint) (Challenge, error) {
s.mu.Lock()
challenge, ok := s.items[key]
delete(s.items, key)
s.mu.Unlock()

if !ok || challenge.Ceremony != ceremony || challenge.UserID != userID || !s.now().Before(challenge.Expires) {
return Challenge{}, ErrChallengeInvalid
}
return challenge, nil
}

func StoreChallenge(challenge Challenge) (string, error) {
return challenges.Put(challenge)
}

func ConsumeChallenge(key string, ceremony Ceremony, userID uint) (Challenge, error) {
return challenges.Consume(key, ceremony, userID)
}
87 changes: 87 additions & 0 deletions internal/authn/challenge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package authn

import (
"errors"
"testing"
"time"

"github.com/go-webauthn/webauthn/webauthn"
)

func TestChallengeRegistrationAuthenticationAndReplay(t *testing.T) {
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
store := newChallengeStore()
store.now = func() time.Time { return now }

for _, ceremony := range []Ceremony{CeremonyRegistration, CeremonyLogin} {
key, err := store.Put(Challenge{
Session: webauthn.SessionData{Challenge: "challenge"},
Ceremony: ceremony,
UserID: 42,
})
if err != nil {
t.Fatalf("Put(%s) error = %v", ceremony, err)
}
if _, err = store.Consume(key, ceremony, 42); err != nil {
t.Fatalf("Consume(%s) error = %v", ceremony, err)
}
if _, err = store.Consume(key, ceremony, 42); !errors.Is(err, ErrChallengeInvalid) {
t.Fatalf("replayed Consume(%s) error = %v, want ErrChallengeInvalid", ceremony, err)
}
}
}

func TestChallengeRejectsExpiredOrWrongBinding(t *testing.T) {
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
store := newChallengeStore()
store.now = func() time.Time { return now }

expired, err := store.Put(Challenge{Ceremony: CeremonyLogin, UserID: 42})
if err != nil {
t.Fatal(err)
}
now = now.Add(ChallengeTTL)
if _, err = store.Consume(expired, CeremonyLogin, 42); !errors.Is(err, ErrChallengeInvalid) {
t.Fatalf("expired challenge error = %v, want ErrChallengeInvalid", err)
}

wrongUser, err := store.Put(Challenge{Ceremony: CeremonyRegistration, UserID: 42})
if err != nil {
t.Fatal(err)
}
if _, err = store.Consume(wrongUser, CeremonyRegistration, 7); !errors.Is(err, ErrChallengeInvalid) {
t.Fatalf("wrong-user challenge error = %v, want ErrChallengeInvalid", err)
}
}

func TestChallengeCapacityEvictsOldestInsteadOfRejectingNewCeremonies(t *testing.T) {
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
store := newChallengeStore()
store.now = func() time.Time { return now }

var oldest string
for i := 0; i < maxChallengeCount; i++ {
key, err := store.Put(Challenge{Ceremony: CeremonyLogin})
if err != nil {
t.Fatal(err)
}
if i == 0 {
oldest = key
}
now = now.Add(time.Nanosecond)
}

newest, err := store.Put(Challenge{Ceremony: CeremonyLogin})
if err != nil {
t.Fatalf("Put at capacity rejected a new ceremony: %v", err)
}
if len(store.items) != maxChallengeCount {
t.Fatalf("challenge count = %d, want %d", len(store.items), maxChallengeCount)
}
if _, err = store.Consume(oldest, CeremonyLogin, 0); !errors.Is(err, ErrChallengeInvalid) {
t.Fatalf("oldest challenge error = %v, want ErrChallengeInvalid", err)
}
if _, err = store.Consume(newest, CeremonyLogin, 0); err != nil {
t.Fatalf("new challenge was not retained: %v", err)
}
}
Loading