From 9a33dbf8ad634b4910269f88f63d0b0b1898466a Mon Sep 17 00:00:00 2001 From: Aaron C Date: Tue, 8 Sep 2026 22:40:56 -0400 Subject: [PATCH] Passkey Anyone??? Add optional WebAuthn passkeys for the web UI (register, login, manage), gated by RMFAKECLOUD_WEBAUTHN. Co-authored-by: Cursor --- docs/install/configuration.md | 5 + docs/install/passkeys-checklist.md | 11 + go.mod | 7 + go.sum | 14 ++ internal/config/config.go | 73 +++++++ internal/config/webauthn_config_test.go | 23 +++ internal/model/user.go | 2 + internal/model/webauthn.go | 169 ++++++++++++++++ internal/model/webauthn_test.go | 94 +++++++++ internal/ui/handlers.go | 30 ++- internal/ui/routes.go | 8 + internal/ui/ui.go | 17 ++ internal/ui/webauthn_handlers.go | 257 ++++++++++++++++++++++++ internal/ui/webauthn_session.go | 69 +++++++ internal/ui/webauthn_session_test.go | 40 ++++ other/rmfakecloud.env | 3 + ui/package.json | 1 + ui/pnpm-lock.yaml | 8 + ui/src/pages/Login/index.jsx | 77 +++++-- ui/src/pages/Profile/Passkeys.jsx | 158 +++++++++++++++ ui/src/pages/Profile/index.jsx | 4 + ui/src/services/api.service.js | 86 ++++++++ 22 files changed, 1134 insertions(+), 22 deletions(-) create mode 100644 docs/install/passkeys-checklist.md create mode 100644 internal/config/webauthn_config_test.go create mode 100644 internal/model/webauthn.go create mode 100644 internal/model/webauthn_test.go create mode 100644 internal/ui/webauthn_handlers.go create mode 100644 internal/ui/webauthn_session.go create mode 100644 internal/ui/webauthn_session_test.go create mode 100644 ui/src/pages/Profile/Passkeys.jsx diff --git a/docs/install/configuration.md b/docs/install/configuration.md index f90466a8..697d6194 100644 --- a/docs/install/configuration.md +++ b/docs/install/configuration.md @@ -12,6 +12,11 @@ The configuration is made through environment variables. | `RM_HTTPS_COOKIE` | For the UI, force cookies to be available only via https | | `RM_TRUST_PROXY` | Trust the proxy for client ip addresses (X-Forwarded-For/X-Real-IP) default false | | `HASH_SCHEMA_VERSION` | Hash tree schema version: "3" or "4" (default: 3) | +| `RMFAKECLOUD_WEBAUTHN` | Enable **passkeys** (WebAuthn) for the web UI (default: `false`). Additive to password login. Requires a real HTTPS browser origin (not `http://hostname:port` or raw IPs). | +| `RMFAKECLOUD_WEBAUTHN_RPID` | Relying Party ID — hostname without scheme or port (e.g. `www.example.com`). If empty and `STORAGE_URL` is `https://…`, derived from that host. | +| `RMFAKECLOUD_WEBAUTHN_ORIGINS` | Comma-separated allowed origins (e.g. `https://www.example.com:3000`). If empty and `STORAGE_URL` is `https://…`, derived as a single origin from it. | + +Manual verification steps: [passkeys checklist](passkeys-checklist.md). ## Handwriting recognition diff --git a/docs/install/passkeys-checklist.md b/docs/install/passkeys-checklist.md new file mode 100644 index 00000000..9b5f5491 --- /dev/null +++ b/docs/install/passkeys-checklist.md @@ -0,0 +1,11 @@ +# Passkeys (WebAuthn) — manual checklist + +Requires `RMFAKECLOUD_WEBAUTHN=true` and HTTPS origin matching `RMFAKECLOUD_WEBAUTHN_RPID` / `ORIGINS` (or derived from https `STORAGE_URL`). + +1. Open the web UI over HTTPS (not `http://acorn:3000` / raw IP). +2. Sign in with email/password. +3. Profile → **Passkeys** → Add passkey (optional label). Complete browser/OS prompt. +4. Sign out. Login page should show **Sign in with passkey**. +5. Use passkey to sign in; land on Documents; cookie/JWT works as usual. +6. Confirm password login still works. +7. Profile → remove passkey; optional: set `RMFAKECLOUD_WEBAUTHN=false` and restart → status/`Passkeys` UI hidden, login button gone. diff --git a/go.mod b/go.mod index 65d8dffa..98a0b565 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22 github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 github.com/gin-gonic/gin v1.9.1 + github.com/go-webauthn/webauthn v0.11.2 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 @@ -34,20 +35,25 @@ require ( github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.19.0 // indirect + github.com/go-webauthn/x v0.1.14 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-tpm v0.9.1 // indirect github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/jung-kurt/gofpdf v1.16.2 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect @@ -62,6 +68,7 @@ require ( github.com/unidoc/timestamp v0.0.0-20200412005513-91597fd3793a // indirect github.com/unidoc/unichart v0.3.0 // indirect github.com/unidoc/unitype v0.4.0 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/arch v0.7.0 // indirect golang.org/x/image v0.18.0 // indirect golang.org/x/net v0.38.0 // indirect diff --git a/go.sum b/go.sum index c3e2fdd0..213d47e0 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -93,10 +95,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= +github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= +github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0= +github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -137,6 +145,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= +github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -186,6 +196,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -251,6 +263,8 @@ github.com/unidoc/unipdf/v3 v3.56.0 h1:15Lt+AZvELP03PH23ypV0y5reKZxCRKSq46SZg+vx github.com/unidoc/unipdf/v3 v3.56.0/go.mod h1:iBr/OsbLnJ49WhJlpfpYS3VmXrkTG05O7rKe9crppmc= github.com/unidoc/unitype v0.4.0 h1:/TMZ3wgwfWWX64mU5x2O9no9UmoBqYCB089LYYqHyQQ= github.com/unidoc/unitype v0.4.0/go.mod h1:HV5zuUeqMKA4QgYQq3KDlJY/P96XF90BQB+6czK6LVA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/config/config.go b/internal/config/config.go index d4979076..364ca19b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/ddvk/rmfakecloud/internal/email" log "github.com/sirupsen/logrus" @@ -81,6 +82,10 @@ const ( envMQTTPort = "MQTT_PORT" envICEServers = "ICE_SERVERS" envHashSchemaVersion = "HASH_SCHEMA_VERSION" + + envWebAuthn = "RMFAKECLOUD_WEBAUTHN" + envWebAuthnRPID = "RMFAKECLOUD_WEBAUTHN_RPID" + envWebAuthnOrigins = "RMFAKECLOUD_WEBAUTHN_ORIGINS" ) // Config config @@ -106,6 +111,12 @@ type Config struct { MQTTPort string ICEServers []interface{} HashSchemaVersion string + // WebAuthn enables passkey login for the web UI (RMFAKECLOUD_WEBAUTHN). Default false. + WebAuthn bool + // WebAuthnRPID is the Relying Party ID (hostname without scheme/port). + WebAuthnRPID string + // WebAuthnOrigins are allowed browser origins for WebAuthn ceremonies. + WebAuthnOrigins []string } // Verify verify @@ -142,6 +153,9 @@ func (cfg *Config) Verify() { } else { log.Info("No ICE servers configured - screenshare will only work on local networks") } + if cfg.WebAuthn { + log.Infof("web UI passkeys enabled (rpid=%q origins=%v)", cfg.WebAuthnRPID, cfg.WebAuthnOrigins) + } } // FromEnv config from environment values @@ -265,6 +279,29 @@ func FromEnv() *Config { log.Fatalf("%s must be either '3' or '4', got: %s", envHashSchemaVersion, hashSchemaVersion) } + webAuthnWanted, _ := strconv.ParseBool(os.Getenv(envWebAuthn)) + webAuthnRPID := strings.TrimSpace(os.Getenv(envWebAuthnRPID)) + webAuthnOrigins := splitCSV(os.Getenv(envWebAuthnOrigins)) + webAuthnEnabled := false + if webAuthnWanted { + if webAuthnRPID == "" || len(webAuthnOrigins) == 0 { + if derivedRPID, derivedOrigins, ok := deriveWebAuthnFromStorageURL(uploadURL); ok { + if webAuthnRPID == "" { + webAuthnRPID = derivedRPID + } + if len(webAuthnOrigins) == 0 { + webAuthnOrigins = derivedOrigins + } + } + } + if webAuthnRPID == "" || len(webAuthnOrigins) == 0 { + log.Errorf("%s=true but %s / %s not set and could not derive from https %s; passkeys disabled", + envWebAuthn, envWebAuthnRPID, envWebAuthnOrigins, EnvStorageURL) + } else { + webAuthnEnabled = true + } + } + cfg := Config{ Port: port, StorageURL: uploadURL, @@ -284,10 +321,36 @@ func FromEnv() *Config { MQTTPort: mqttPort, ICEServers: iceServers, HashSchemaVersion: hashSchemaVersion, + WebAuthn: webAuthnEnabled, + WebAuthnRPID: webAuthnRPID, + WebAuthnOrigins: webAuthnOrigins, } return &cfg } +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// deriveWebAuthnFromStorageURL returns RPID (hostname without port) and a single origin +// when STORAGE_URL is https with a host. http / empty host returns ok=false. +func deriveWebAuthnFromStorageURL(storageURL string) (rpid string, origins []string, ok bool) { + u, err := url.Parse(storageURL) + if err != nil || u.Scheme != "https" || u.Hostname() == "" { + return "", nil, false + } + origin := "https://" + u.Host + return u.Hostname(), []string{origin}, true +} + // normalizeICEServers expands "urls" arrays into singular "url" entries; xochitl rejects anything else func normalizeICEServers(servers []interface{}) []interface{} { normalized := make([]interface{}, 0, len(servers)) @@ -378,6 +441,12 @@ General: %s Trust the proxy for X-Forwarded-For/X-Real-IP (set only if behind a proxy) %s Hash tree schema version: "3" or "4" (default: 3) +Web UI passkeys (WebAuthn): + %s Enable passkey register/login for the web UI (default: false). Requires HTTPS browser origin. + %s Relying Party ID (hostname without scheme/port). If empty, derived from https STORAGE_URL. + %s Comma-separated allowed origins (e.g. https://example.com:3000). If empty, derived from https STORAGE_URL. + http://hostname, LAN names, and raw IPs do not work for real passkeys. + MQTT (for screenshare): %s MQTT TCP port (default: 8883) %s ICE servers for WebRTC (JSON array format) @@ -415,6 +484,10 @@ myScript hwr (needs a developer account): envTrustProxy, envHashSchemaVersion, + envWebAuthn, + envWebAuthnRPID, + envWebAuthnOrigins, + envMQTTPort, envICEServers, diff --git a/internal/config/webauthn_config_test.go b/internal/config/webauthn_config_test.go new file mode 100644 index 00000000..8472f40a --- /dev/null +++ b/internal/config/webauthn_config_test.go @@ -0,0 +1,23 @@ +package config + +import "testing" + +func TestDeriveWebAuthnFromStorageURL(t *testing.T) { + rpid, origins, ok := deriveWebAuthnFromStorageURL("https://www.example.com:3000") + if !ok || rpid != "www.example.com" || len(origins) != 1 || origins[0] != "https://www.example.com:3000" { + t.Fatalf("got rpid=%q origins=%v ok=%v", rpid, origins, ok) + } + if _, _, ok := deriveWebAuthnFromStorageURL("http://acorn:3000"); ok { + t.Fatal("http should not derive") + } + if _, _, ok := deriveWebAuthnFromStorageURL("not-a-url"); ok { + t.Fatal("invalid should not derive") + } +} + +func TestSplitCSV(t *testing.T) { + got := splitCSV(" https://a.com ,https://b.com, ") + if len(got) != 2 || got[0] != "https://a.com" || got[1] != "https://b.com" { + t.Fatalf("%v", got) + } +} diff --git a/internal/model/user.go b/internal/model/user.go index 62ab58e7..c605f42e 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -55,6 +55,8 @@ type User struct { AdditionalScopes []string // Integrations stores the list of "Integrations" as shown on the tablet. Integrations []IntegrationConfig + // WebAuthnCredentials are passkeys registered for the web UI. + WebAuthnCredentials []WebAuthnCredential `yaml:"webauthncredentials,omitempty"` } // IntegrationConfig config for various integrations diff --git a/internal/model/webauthn.go b/internal/model/webauthn.go new file mode 100644 index 00000000..0ed30e01 --- /dev/null +++ b/internal/model/webauthn.go @@ -0,0 +1,169 @@ +package model + +import ( + "bytes" + "encoding/base64" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// WebAuthnCredential is a stored passkey / WebAuthn public-key credential. +type WebAuthnCredential struct { + ID []byte `yaml:"id"` + PublicKey []byte `yaml:"publickey"` + AttestationType string `yaml:"attestationtype,omitempty"` + Transport []string `yaml:"transport,omitempty"` + UserPresent bool `yaml:"userpresent,omitempty"` + UserVerified bool `yaml:"userverified,omitempty"` + BackupEligible bool `yaml:"backupeligible,omitempty"` + BackupState bool `yaml:"backupstate,omitempty"` + AAGUID []byte `yaml:"aaguid,omitempty"` + SignCount uint32 `yaml:"signcount"` + Attachment string `yaml:"attachment,omitempty"` + Name string `yaml:"name,omitempty"` + CreatedAt time.Time `yaml:"createdat,omitempty"` +} + +// WebAuthnUser adapts *User to webauthn.User. +type WebAuthnUser struct { + *User +} + +func (u WebAuthnUser) WebAuthnID() []byte { + if u.User == nil { + return nil + } + return []byte(u.ID) +} + +func (u WebAuthnUser) WebAuthnName() string { + if u.User == nil { + return "" + } + return u.Email +} + +func (u WebAuthnUser) WebAuthnDisplayName() string { + if u.User == nil { + return "" + } + if u.Name != "" { + return u.Name + } + return u.Email +} + +func (u WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + if u.User == nil { + return nil + } + out := make([]webauthn.Credential, 0, len(u.User.WebAuthnCredentials)) + for _, c := range u.User.WebAuthnCredentials { + out = append(out, c.ToLibrary()) + } + return out +} + +// ToLibrary converts a stored credential to the go-webauthn type. +func (c WebAuthnCredential) ToLibrary() webauthn.Credential { + transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transport)) + for _, t := range c.Transport { + transports = append(transports, protocol.AuthenticatorTransport(t)) + } + return webauthn.Credential{ + ID: c.ID, + PublicKey: c.PublicKey, + AttestationType: c.AttestationType, + Transport: transports, + Flags: webauthn.CredentialFlags{ + UserPresent: c.UserPresent, + UserVerified: c.UserVerified, + BackupEligible: c.BackupEligible, + BackupState: c.BackupState, + }, + Authenticator: webauthn.Authenticator{ + AAGUID: c.AAGUID, + SignCount: c.SignCount, + Attachment: protocol.AuthenticatorAttachment(c.Attachment), + }, + } +} + +// FromLibraryCredential builds a stored credential from a successful registration. +func FromLibraryCredential(cred *webauthn.Credential, name string) WebAuthnCredential { + if cred == nil { + return WebAuthnCredential{} + } + transports := make([]string, 0, len(cred.Transport)) + for _, t := range cred.Transport { + transports = append(transports, string(t)) + } + return WebAuthnCredential{ + ID: append([]byte(nil), cred.ID...), + PublicKey: append([]byte(nil), cred.PublicKey...), + AttestationType: cred.AttestationType, + Transport: transports, + UserPresent: cred.Flags.UserPresent, + UserVerified: cred.Flags.UserVerified, + BackupEligible: cred.Flags.BackupEligible, + BackupState: cred.Flags.BackupState, + AAGUID: append([]byte(nil), cred.Authenticator.AAGUID...), + SignCount: cred.Authenticator.SignCount, + Attachment: string(cred.Authenticator.Attachment), + Name: name, + CreatedAt: time.Now().UTC(), + } +} + +// UpdateWebAuthnCredentialSignCount updates sign count / backup flags after login. +func (u *User) UpdateWebAuthnCredentialSignCount(credID []byte, cred *webauthn.Credential) bool { + if u == nil || cred == nil { + return false + } + for i := range u.WebAuthnCredentials { + if bytes.Equal(u.WebAuthnCredentials[i].ID, credID) { + u.WebAuthnCredentials[i].SignCount = cred.Authenticator.SignCount + u.WebAuthnCredentials[i].BackupState = cred.Flags.BackupState + u.WebAuthnCredentials[i].UserPresent = cred.Flags.UserPresent + u.WebAuthnCredentials[i].UserVerified = cred.Flags.UserVerified + return true + } + } + return false +} + +// RemoveWebAuthnCredential deletes a credential by raw id. Returns true if removed. +func (u *User) RemoveWebAuthnCredential(credID []byte) bool { + if u == nil { + return false + } + for i := range u.WebAuthnCredentials { + if bytes.Equal(u.WebAuthnCredentials[i].ID, credID) { + u.WebAuthnCredentials = append(u.WebAuthnCredentials[:i], u.WebAuthnCredentials[i+1:]...) + return true + } + } + return false +} + +// FindUserByWebAuthnHandle finds a user whose WebAuthnID matches userHandle. +func FindUserByWebAuthnHandle(users []*User, userHandle []byte) *User { + for _, u := range users { + if u != nil && bytes.Equal([]byte(u.ID), userHandle) { + return u + } + } + return nil +} + +// CredentialIDBase64 returns URL-safe base64 without padding for API paths. +func CredentialIDBase64(id []byte) string { + return base64.RawURLEncoding.EncodeToString(id) +} + +// ParseCredentialIDBase64 decodes a URL-safe credential id. +func ParseCredentialIDBase64(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} diff --git a/internal/model/webauthn_test.go b/internal/model/webauthn_test.go new file mode 100644 index 00000000..1924f3df --- /dev/null +++ b/internal/model/webauthn_test.go @@ -0,0 +1,94 @@ +package model + +import ( + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +func TestWebAuthnCredentialRoundTrip(t *testing.T) { + cred := &webauthn.Credential{ + ID: []byte{1, 2, 3, 4}, + PublicKey: []byte{9, 8, 7}, + AttestationType: "none", + Flags: webauthn.CredentialFlags{ + UserPresent: true, + UserVerified: true, + }, + Authenticator: webauthn.Authenticator{ + AAGUID: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + SignCount: 3, + }, + } + stored := FromLibraryCredential(cred, "Laptop") + if stored.Name != "Laptop" || stored.SignCount != 3 { + t.Fatalf("unexpected stored credential: %+v", stored) + } + if stored.CreatedAt.IsZero() { + t.Fatal("CreatedAt should be set") + } + lib := stored.ToLibrary() + if string(lib.ID) != string(cred.ID) || string(lib.PublicKey) != string(cred.PublicKey) { + t.Fatalf("round-trip mismatch: %+v vs %+v", lib, cred) + } +} + +func TestUpdateAndRemoveWebAuthnCredential(t *testing.T) { + u := &User{ + ID: "alice@example.com", + WebAuthnCredentials: []WebAuthnCredential{ + {ID: []byte("abc"), SignCount: 1, Name: "a", CreatedAt: time.Now()}, + }, + } + updated := u.UpdateWebAuthnCredentialSignCount([]byte("abc"), &webauthn.Credential{ + Authenticator: webauthn.Authenticator{SignCount: 5}, + Flags: webauthn.CredentialFlags{BackupState: true}, + }) + if !updated || u.WebAuthnCredentials[0].SignCount != 5 { + t.Fatalf("sign count not updated: %+v", u.WebAuthnCredentials[0]) + } + if !u.RemoveWebAuthnCredential([]byte("abc")) || len(u.WebAuthnCredentials) != 0 { + t.Fatal("remove failed") + } +} + +func TestFindUserByWebAuthnHandle(t *testing.T) { + users := []*User{ + {ID: "a@example.com"}, + {ID: "b@example.com"}, + } + found := FindUserByWebAuthnHandle(users, []byte("b@example.com")) + if found == nil || found.ID != "b@example.com" { + t.Fatalf("expected b, got %#v", found) + } + if FindUserByWebAuthnHandle(users, []byte("missing")) != nil { + t.Fatal("expected nil") + } +} + +func TestCredentialIDBase64(t *testing.T) { + id := []byte{0xff, 0x00, 0x01} + s := CredentialIDBase64(id) + got, err := ParseCredentialIDBase64(s) + if err != nil { + t.Fatal(err) + } + if string(got) != string(id) { + t.Fatalf("got %v want %v", got, id) + } +} + +func TestWebAuthnUserInterface(t *testing.T) { + u := &User{ID: "u1", Email: "u1@example.com", Name: "User One"} + wu := WebAuthnUser{User: u} + if string(wu.WebAuthnID()) != "u1" { + t.Fatal(wu.WebAuthnID()) + } + if wu.WebAuthnName() != "u1@example.com" || wu.WebAuthnDisplayName() != "User One" { + t.Fatalf("%s / %s", wu.WebAuthnName(), wu.WebAuthnDisplayName()) + } + if len(wu.WebAuthnCredentials()) != 0 { + t.Fatal("expected empty credentials") + } +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 54c5b579..f6b54d89 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -130,6 +130,23 @@ func (app *ReactAppWrapper) login(c *gin.Context) { return } + tokenString, expiresAfter, err := app.issueWebTokenForUser(user, uuid.NewString()) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + log.Debug("cookie expires after: ", expiresAfter) + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(cookieName, tokenString, int(expiresAfter.Seconds()), "/", "", app.cfg.HTTPSCookie, true) + + c.String(http.StatusOK, tokenString) +} + +func (app *ReactAppWrapper) issueWebTokenForUser(user *model.User, browserID string) (string, time.Duration, error) { + if user == nil { + return "", 0, fmt.Errorf("user is nil") + } scopes := "" if user.Sync15 { scopes = isSync15Key @@ -138,7 +155,7 @@ func (app *ReactAppWrapper) login(c *gin.Context) { expires := time.Now().Add(expiresAfter) claims := &WebUserClaims{ UserID: user.ID, - BrowserID: uuid.NewString(), + BrowserID: browserID, Email: user.Email, Scopes: scopes, RegisteredClaims: jwt.RegisteredClaims{ @@ -154,17 +171,10 @@ func (app *ReactAppWrapper) login(c *gin.Context) { } tokenString, err := common.SignClaims(claims, app.cfg.JWTSecretKey) - if err != nil { - log.Error(err) - c.AbortWithStatus(http.StatusInternalServerError) - return + return "", 0, err } - log.Debug("cookie expires after: ", expiresAfter) - c.SetSameSite(http.SameSiteStrictMode) - c.SetCookie(cookieName, tokenString, int(expiresAfter.Seconds()), "/", "", app.cfg.HTTPSCookie, true) - - c.String(http.StatusOK, tokenString) + return tokenString, expiresAfter, nil } func (app *ReactAppWrapper) changePassword(c *gin.Context) { diff --git a/internal/ui/routes.go b/internal/ui/routes.go index a463f7f2..9f22ed75 100644 --- a/internal/ui/routes.go +++ b/internal/ui/routes.go @@ -37,6 +37,9 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { r := router.Group("/ui/api") r.POST("register", app.register) r.POST("login", app.login) + r.GET("webauthn/status", app.webAuthnStatus) + r.POST("webauthn/login/begin", app.webAuthnLoginBegin) + r.POST("webauthn/login/finish", app.webAuthnLoginFinish) r.GET("logout", func(c *gin.Context) { c.SetCookie(cookieName, "/", -1, "", "", false, true) c.Status(http.StatusOK) @@ -65,6 +68,11 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { auth.POST("profile", app.changePassword) // auth.POST("changeEmail", app.changePassword) + auth.POST("webauthn/register/begin", app.webAuthnRegisterBegin) + auth.POST("webauthn/register/finish", app.webAuthnRegisterFinish) + auth.GET("webauthn/credentials", app.webAuthnListCredentials) + auth.DELETE("webauthn/credentials/:id", app.webAuthnDeleteCredential) + auth.GET("documents", app.listDocuments) auth.GET("documents/:docid", app.getDocument) auth.POST("documents/upload", app.createDocument) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6a7d6c98..67829997 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -18,6 +18,8 @@ import ( "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" webui "github.com/ddvk/rmfakecloud/ui" "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/webauthn" + log "github.com/sirupsen/logrus" ) type backend interface { @@ -77,6 +79,8 @@ type ReactAppWrapper struct { backends map[common.SyncVersion]backend roomManager *screenshare.RoomManager mqtt mqttBridge + webAuthn *webauthn.WebAuthn + webAuthnSessions *webAuthnSessionStore } // hack for serving index.html on / @@ -121,6 +125,19 @@ func New(cfg *config.Config, roomManager: roomManager, mqtt: mqttBroker, } + if cfg != nil && cfg.WebAuthn { + wa, err := webauthn.New(&webauthn.Config{ + RPDisplayName: "rmfakecloud", + RPID: cfg.WebAuthnRPID, + RPOrigins: cfg.WebAuthnOrigins, + }) + if err != nil { + log.Errorf("webauthn init failed, passkeys disabled: %v", err) + } else { + staticWrapper.webAuthn = wa + staticWrapper.webAuthnSessions = newWebAuthnSessionStore() + } + } return &staticWrapper } diff --git a/internal/ui/webauthn_handlers.go b/internal/ui/webauthn_handlers.go new file mode 100644 index 00000000..aad46e26 --- /dev/null +++ b/internal/ui/webauthn_handlers.go @@ -0,0 +1,257 @@ +package ui + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/ddvk/rmfakecloud/internal/model" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" +) + +type webAuthnCeremonyResponse struct { + SessionID string `json:"sessionId"` + Options interface{} `json:"publicKey"` +} + +type webAuthnFinishRequest struct { + SessionID string `json:"sessionId"` + Credential json.RawMessage `json:"credential"` + Name string `json:"name,omitempty"` +} + +type webAuthnCredentialView struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` +} + +func (app *ReactAppWrapper) webAuthnEnabled() bool { + return app.cfg != nil && app.cfg.WebAuthn && app.webAuthn != nil && app.webAuthnSessions != nil +} + +func (app *ReactAppWrapper) webAuthnStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"enabled": app.webAuthnEnabled()}) +} + +func (app *ReactAppWrapper) webAuthnRegisterBegin(c *gin.Context) { + if !app.webAuthnEnabled() { + c.AbortWithStatus(http.StatusNotFound) + return + } + uid := userID(c) + user, err := app.userStorer.GetUser(uid) + if err != nil || user == nil { + log.Error(uiLogger, "webauthn register begin: ", err) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + waUser := model.WebAuthnUser{User: user} + opts := []webauthn.RegistrationOption{ + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred), + webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementPreferred, + UserVerification: protocol.VerificationPreferred, + }), + } + creation, session, err := app.webAuthn.BeginRegistration(waUser, opts...) + if err != nil { + log.Error(uiLogger, "webauthn BeginRegistration: ", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + sid := app.webAuthnSessions.Put(session) + c.JSON(http.StatusOK, webAuthnCeremonyResponse{ + SessionID: sid, + Options: creation.Response, + }) +} + +func (app *ReactAppWrapper) webAuthnRegisterFinish(c *gin.Context) { + if !app.webAuthnEnabled() { + c.AbortWithStatus(http.StatusNotFound) + return + } + var req webAuthnFinishRequest + if err := c.ShouldBindJSON(&req); err != nil || req.SessionID == "" || len(req.Credential) == 0 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + session, ok := app.webAuthnSessions.Take(req.SessionID) + if !ok { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid or expired session"}) + return + } + uid := userID(c) + user, err := app.userStorer.GetUser(uid) + if err != nil || user == nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(req.Credential)) + if err != nil { + log.Error(uiLogger, "webauthn parse register: ", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid credential"}) + return + } + cred, err := app.webAuthn.CreateCredential(model.WebAuthnUser{User: user}, session, parsed) + if err != nil { + log.Error(uiLogger, "webauthn CreateCredential: ", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + name = "Passkey" + } + user.WebAuthnCredentials = append(user.WebAuthnCredentials, model.FromLibraryCredential(cred, name)) + user.UpdatedAt = time.Now() + if err := app.userStorer.UpdateUser(user); err != nil { + log.Error(uiLogger, "webauthn save credential: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, gin.H{ + "id": model.CredentialIDBase64(cred.ID), + "name": name, + }) +} + +func (app *ReactAppWrapper) webAuthnListCredentials(c *gin.Context) { + if !app.webAuthnEnabled() { + c.AbortWithStatus(http.StatusNotFound) + return + } + uid := userID(c) + user, err := app.userStorer.GetUser(uid) + if err != nil || user == nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + out := make([]webAuthnCredentialView, 0, len(user.WebAuthnCredentials)) + for _, cred := range user.WebAuthnCredentials { + out = append(out, webAuthnCredentialView{ + ID: model.CredentialIDBase64(cred.ID), + Name: cred.Name, + CreatedAt: cred.CreatedAt, + }) + } + c.JSON(http.StatusOK, out) +} + +func (app *ReactAppWrapper) webAuthnDeleteCredential(c *gin.Context) { + if !app.webAuthnEnabled() { + c.AbortWithStatus(http.StatusNotFound) + return + } + credID, err := model.ParseCredentialIDBase64(c.Param("id")) + if err != nil || len(credID) == 0 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + uid := userID(c) + user, err := app.userStorer.GetUser(uid) + if err != nil || user == nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + if !user.RemoveWebAuthnCredential(credID) { + c.AbortWithStatus(http.StatusNotFound) + return + } + user.UpdatedAt = time.Now() + if err := app.userStorer.UpdateUser(user); err != nil { + log.Error(uiLogger, "webauthn delete credential: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.Status(http.StatusNoContent) +} + +func (app *ReactAppWrapper) webAuthnLoginBegin(c *gin.Context) { + if !app.webAuthnEnabled() { + c.AbortWithStatus(http.StatusNotFound) + return + } + assertion, session, err := app.webAuthn.BeginDiscoverableLogin( + webauthn.WithUserVerification(protocol.VerificationPreferred), + ) + if err != nil { + log.Error(uiLogger, "webauthn BeginDiscoverableLogin: ", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + sid := app.webAuthnSessions.Put(session) + c.JSON(http.StatusOK, webAuthnCeremonyResponse{ + SessionID: sid, + Options: assertion.Response, + }) +} + +func (app *ReactAppWrapper) webAuthnLoginFinish(c *gin.Context) { + if !app.webAuthnEnabled() { + c.AbortWithStatus(http.StatusNotFound) + return + } + var req webAuthnFinishRequest + if err := c.ShouldBindJSON(&req); err != nil || req.SessionID == "" || len(req.Credential) == 0 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + session, ok := app.webAuthnSessions.Take(req.SessionID) + if !ok { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid or expired session"}) + return + } + parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(req.Credential)) + if err != nil { + log.Error(uiLogger, "webauthn parse login: ", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid credential"}) + return + } + handler := func(rawID, userHandle []byte) (webauthn.User, error) { + users, err := app.userStorer.GetUsers() + if err != nil { + return nil, err + } + u := model.FindUserByWebAuthnHandle(users, userHandle) + if u == nil { + return nil, protocol.ErrBadRequest.WithDetails("user not found") + } + return model.WebAuthnUser{User: u}, nil + } + waUser, cred, err := app.webAuthn.ValidatePasskeyLogin(handler, session, parsed) + if err != nil { + log.Warn(uiLogger, "webauthn login failed: ", err) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + wrapped, ok := waUser.(model.WebAuthnUser) + if !ok || wrapped.User == nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + user := wrapped.User + if user.UpdateWebAuthnCredentialSignCount(cred.ID, cred) { + user.UpdatedAt = time.Now() + if err := app.userStorer.UpdateUser(user); err != nil { + log.Warn(uiLogger, "webauthn persist sign count: ", err) + } + } + + tokenString, expiresAfter, err := app.issueWebTokenForUser(user, uuid.NewString()) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(cookieName, tokenString, int(expiresAfter.Seconds()), "/", "", app.cfg.HTTPSCookie, true) + c.String(http.StatusOK, tokenString) +} diff --git a/internal/ui/webauthn_session.go b/internal/ui/webauthn_session.go new file mode 100644 index 00000000..61d836e3 --- /dev/null +++ b/internal/ui/webauthn_session.go @@ -0,0 +1,69 @@ +package ui + +import ( + "sync" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" +) + +const webAuthnSessionTTL = 2 * time.Minute + +type webAuthnSessionStore struct { + mu sync.Mutex + data map[string]webAuthnSessionEntry +} + +type webAuthnSessionEntry struct { + Data webauthn.SessionData + Expires time.Time +} + +func newWebAuthnSessionStore() *webAuthnSessionStore { + return &webAuthnSessionStore{data: make(map[string]webAuthnSessionEntry)} +} + +func (s *webAuthnSessionStore) Put(session *webauthn.SessionData) string { + id := uuid.NewString() + s.mu.Lock() + defer s.mu.Unlock() + s.purgeLocked(time.Now()) + s.data[id] = webAuthnSessionEntry{ + Data: *session, + Expires: time.Now().Add(webAuthnSessionTTL), + } + return id +} + +func (s *webAuthnSessionStore) Take(id string) (webauthn.SessionData, bool) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + s.purgeLocked(now) + entry, ok := s.data[id] + if !ok { + return webauthn.SessionData{}, false + } + delete(s.data, id) + if now.After(entry.Expires) { + return webauthn.SessionData{}, false + } + return entry.Data, true +} + +func (s *webAuthnSessionStore) purgeLocked(now time.Time) { + for k, v := range s.data { + if now.After(v.Expires) { + delete(s.data, k) + } + } +} + +// LenForTest returns current session count (tests only). +func (s *webAuthnSessionStore) LenForTest() int { + s.mu.Lock() + defer s.mu.Unlock() + s.purgeLocked(time.Now()) + return len(s.data) +} diff --git a/internal/ui/webauthn_session_test.go b/internal/ui/webauthn_session_test.go new file mode 100644 index 00000000..44ea76e2 --- /dev/null +++ b/internal/ui/webauthn_session_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +func TestWebAuthnSessionStorePutTake(t *testing.T) { + s := newWebAuthnSessionStore() + id := s.Put(&webauthn.SessionData{Challenge: "abc"}) + if id == "" { + t.Fatal("empty session id") + } + data, ok := s.Take(id) + if !ok || data.Challenge != "abc" { + t.Fatalf("take failed: ok=%v data=%+v", ok, data) + } + if _, ok := s.Take(id); ok { + t.Fatal("session should be single-use") + } +} + +func TestWebAuthnSessionStoreExpiry(t *testing.T) { + s := newWebAuthnSessionStore() + id := "expired" + s.mu.Lock() + s.data[id] = webAuthnSessionEntry{ + Data: webauthn.SessionData{Challenge: "x"}, + Expires: time.Now().Add(-time.Second), + } + s.mu.Unlock() + if _, ok := s.Take(id); ok { + t.Fatal("expired session should not be returned") + } + if s.LenForTest() != 0 { + t.Fatalf("expected purged store, len=%d", s.LenForTest()) + } +} diff --git a/other/rmfakecloud.env b/other/rmfakecloud.env index 6aeff3f8..5dade240 100644 --- a/other/rmfakecloud.env +++ b/other/rmfakecloud.env @@ -3,3 +3,6 @@ JWT_SECRET_KEY=tbd DATADIR=/var/rmfakecloud/ #RM_SMTP_USER= #RM_SMTP_ADDRESS= +#RMFAKECLOUD_WEBAUTHN=true +#RMFAKECLOUD_WEBAUTHN_RPID=www.example.com +#RMFAKECLOUD_WEBAUTHN_ORIGINS=https://www.example.com:3000 diff --git a/ui/package.json b/ui/package.json index 8b7ebde9..06c7d50c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-table": "^8.21.3", "bootstrap": "^5.3.3", "jwt-decode": "^4.0.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 760748a5..748afc52 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -452,6 +455,9 @@ packages: cpu: [x64] os: [win32] + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + '@swc/core-darwin-arm64@1.9.2': resolution: {integrity: sha512-nETmsCoY29krTF2PtspEgicb3tqw7Ci5sInTI03EU5zpqYbPjoPH99BVTjj0OsF53jP5MxwnLI5Hm21lUn1d6A==} engines: {node: '>=10'} @@ -2093,6 +2099,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.44.1': optional: true + '@simplewebauthn/browser@13.3.0': {} + '@swc/core-darwin-arm64@1.9.2': optional: true diff --git a/ui/src/pages/Login/index.jsx b/ui/src/pages/Login/index.jsx index 31497ef4..2083c892 100644 --- a/ui/src/pages/Login/index.jsx +++ b/ui/src/pages/Login/index.jsx @@ -1,9 +1,11 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import { Button, Form } from "react-bootstrap"; +import { startAuthentication, browserSupportsWebAuthn } from "@simplewebauthn/browser"; import { useAuthState } from "../../common/useAuthContext"; import { loginUser } from "../../common/actions"; +import apiService from "../../services/api.service"; import styles from "./Login.module.scss"; @@ -11,22 +13,63 @@ const Login = () => { let history = useHistory(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [passkeyEnabled, setPasskeyEnabled] = useState(false); + const [passkeyBusy, setPasskeyBusy] = useState(false); - const { state, dispatch } = useAuthState(); //read the values of loading and errorMessage from context + const { state, dispatch } = useAuthState(); const { errorMessage, loading } = state; + useEffect(() => { + let cancelled = false; + (async () => { + if (!browserSupportsWebAuthn()) return; + try { + const st = await apiService.webAuthnStatus(); + if (!cancelled) setPasskeyEnabled(Boolean(st && st.enabled)); + } catch (_) { + if (!cancelled) setPasskeyEnabled(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + const handleLogin = async (e) => { e.preventDefault(); let payload = { email: username, password }; try { await loginUser(dispatch, payload); - history.push("/documents"); //TODO: usenavigate or return redirect + history.push("/documents"); } catch (error) { console.log(error); } }; + const handlePasskeyLogin = async (e) => { + e.preventDefault(); + setPasskeyBusy(true); + dispatch({ type: "REQUEST_LOGIN" }); + try { + const begin = await apiService.webAuthnLoginBegin(); + const credential = await startAuthentication({ optionsJSON: begin.publicKey }); + const user = await apiService.webAuthnLoginFinish(begin.sessionId, credential); + dispatch({ + type: "LOGIN_SUCCESS", + payload: { user }, + }); + history.push("/documents"); + } catch (error) { + dispatch({ + type: "LOGIN_ERROR", + error: "Passkey login failed: " + (error.message || String(error)), + }); + } finally { + setPasskeyBusy(false); + } + }; + return (
@@ -40,10 +83,10 @@ const Login = () => { value={username} autoFocus onChange={(e) => setUsername(e.target.value)} - disabled={loading} - placeholder="Username" - autoComplete="username" - /> + disabled={loading || passkeyBusy} + placeholder="Username" + autoComplete="username webauthn" + /> @@ -53,17 +96,27 @@ const Login = () => { id="password" value={password} onChange={(e) => setPassword(e.target.value)} - disabled={loading} - placeholder="Password" + disabled={loading || passkeyBusy} + placeholder="Password" autoComplete="current-password" - /> + /> - + {passkeyEnabled ? ( + + ) : null} -
); diff --git a/ui/src/pages/Profile/Passkeys.jsx b/ui/src/pages/Profile/Passkeys.jsx new file mode 100644 index 00000000..1d9e1a19 --- /dev/null +++ b/ui/src/pages/Profile/Passkeys.jsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useState } from "react"; +import Table from "react-bootstrap/Table"; +import Spinner from "react-bootstrap/Spinner"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; +import { toast } from "react-toastify"; +import { startRegistration, browserSupportsWebAuthn } from "@simplewebauthn/browser"; + +import apiservice from "../../services/api.service"; + +function formatWhen(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +export default function Passkeys() { + const [enabled, setEnabled] = useState(false); + const [credentials, setCredentials] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [name, setName] = useState(""); + + const load = useCallback(() => { + setLoading(true); + return apiservice + .webAuthnStatus() + .then((st) => { + const on = Boolean(st && st.enabled) && browserSupportsWebAuthn(); + setEnabled(on); + if (!on) { + setCredentials([]); + setError(null); + return null; + } + return apiservice.listWebAuthnCredentials(); + }) + .then((data) => { + if (data == null) return; + setCredentials(Array.isArray(data) ? data : []); + setError(null); + }) + .catch((e) => { + setError(e.message || String(e)); + setCredentials([]); + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + async function handleAdd() { + setBusy(true); + try { + const begin = await apiservice.webAuthnRegisterBegin(); + const credential = await startRegistration({ optionsJSON: begin.publicKey }); + await apiservice.webAuthnRegisterFinish(begin.sessionId, credential, name.trim() || "Passkey"); + setName(""); + toast.success("Passkey registered."); + await load(); + } catch (e) { + toast.error(e.message || String(e)); + } finally { + setBusy(false); + } + } + + async function handleDelete(id) { + setBusy(true); + try { + await apiservice.deleteWebAuthnCredential(id); + toast.success("Passkey removed."); + await load(); + } catch (e) { + toast.error(e.message || String(e)); + } finally { + setBusy(false); + } + } + + if (!enabled && !loading) { + return null; + } + + return ( +
+

Passkeys

+

+ Register a passkey for passwordless sign-in on this site. Password login remains available. +

+ {loading ? ( + + ) : error ? ( +

{error}

+ ) : ( + <> +
{ + e.preventDefault(); + handleAdd(); + }} + > + + Label + setName(e.target.value)} + placeholder="e.g. Laptop" + disabled={busy} + /> + + +
+ {credentials.length === 0 ? ( +

No passkeys registered yet.

+ ) : ( + + + + + + + + + + {credentials.map((c) => ( + + + + + + ))} + +
NameCreatedActions
{c.name || "Passkey"}{formatWhen(c.createdAt)} + +
+ )} + + )} +
+ ); +} diff --git a/ui/src/pages/Profile/index.jsx b/ui/src/pages/Profile/index.jsx index fcf22948..dae56d4f 100644 --- a/ui/src/pages/Profile/index.jsx +++ b/ui/src/pages/Profile/index.jsx @@ -3,6 +3,7 @@ import Stack from "react-bootstrap/Stack"; import { useAuthState } from "../../common/useAuthContext"; import ResetPassword from "./ResetPassword"; +import Passkeys from "./Passkeys"; const Home = () => { const { state: { user } } = useAuthState(); @@ -12,6 +13,9 @@ const Home = () => {
{user.scopes === "sync15" && (Using sync 15)}
+
+ +
diff --git a/ui/src/services/api.service.js b/ui/src/services/api.service.js index f763066e..fb8ab8f9 100644 --- a/ui/src/services/api.service.js +++ b/ui/src/services/api.service.js @@ -33,6 +33,92 @@ class ApiServices { return user; }); } + webAuthnStatus() { + return fetch(`${constants.ROOT_URL}/webauthn/status`, { + method: "GET", + headers: this.header(), + credentials: "same-origin", + }).then(async (r) => { + if (!r.ok) return { enabled: false }; + return r.json(); + }).catch(() => ({ enabled: false })); + } + webAuthnRegisterBegin() { + return fetch(`${constants.ROOT_URL}/webauthn/register/begin`, { + method: "POST", + headers: this.header(), + credentials: "same-origin", + }).then(async (r) => { + handleError(r); + return r.json(); + }); + } + webAuthnRegisterFinish(sessionId, credential, name) { + return fetch(`${constants.ROOT_URL}/webauthn/register/finish`, { + method: "POST", + headers: this.header(), + credentials: "same-origin", + body: JSON.stringify({ sessionId, credential, name }), + }).then(async (r) => { + handleError(r); + return r.json(); + }); + } + listWebAuthnCredentials() { + return fetch(`${constants.ROOT_URL}/webauthn/credentials`, { + method: "GET", + headers: this.header(), + credentials: "same-origin", + }).then(async (r) => { + handleError(r); + return r.json(); + }); + } + deleteWebAuthnCredential(id) { + return fetch(`${constants.ROOT_URL}/webauthn/credentials/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: this.header(), + credentials: "same-origin", + }).then((r) => handleError(r)); + } + webAuthnLoginBegin() { + return fetch(`${constants.ROOT_URL}/webauthn/login/begin`, { + method: "POST", + headers: this.header(), + credentials: "same-origin", + }).then(async (r) => { + handleError(r); + return r.json(); + }); + } + webAuthnLoginFinish(sessionId, credential) { + return fetch(`${constants.ROOT_URL}/webauthn/login/finish`, { + method: "POST", + headers: this.header(), + credentials: "same-origin", + body: JSON.stringify({ sessionId, credential }), + }) + .then(async (r) => { + const text = await r.text(); + if (!r.ok) { + let msg = r.statusText; + try { + if (text && text.startsWith("{")) { + const j = JSON.parse(text); + if (j.error) msg = j.error; + } + } catch (_) {} + throw new Error(msg); + } + return text; + }) + .then((text) => { + let user = jwtDecode(text); + localStorage.setItem("currentUser", JSON.stringify(user)); + localStorage.setItem("authToken", text); + return user; + }); + } logout() { removeUser(); fetch(`${constants.ROOT_URL}/logout`);