diff --git a/go.mod b/go.mod index 8957d4d0..026c4c3f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/PuerkitoBio/goquery v1.8.1 github.com/abadojack/whatlanggo v1.0.1 github.com/coreos/go-oidc/v3 v3.7.0 + github.com/go-webauthn/webauthn v0.8.6 github.com/gorilla/mux v1.8.0 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.17.0 @@ -19,17 +20,26 @@ require ( ) require ( - github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/go-webauthn/x v0.1.4 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect + github.com/google/go-tpm v0.9.0 // indirect +) + +require ( + github.com/andybalholm/cascadia v1.3.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/fxamacker/cbor/v2 v2.5.0 // indirect github.com/go-jose/go-jose/v3 v3.0.0 // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/procfs v0.11.1 // indirect - github.com/stretchr/testify v1.8.4 // indirect github.com/tdewolff/parse/v2 v2.7.4 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index 0b2f1c6b..8a1e3da0 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,9 @@ github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAc github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= -github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -13,8 +14,16 @@ github.com/coreos/go-oidc/v3 v3.7.0/go.mod h1:yQzSCqBnK3e6Fs5l+f5i0F8Kwf0zpH9bPE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= +github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-webauthn/webauthn v0.8.6 h1:bKMtL1qzd2WTFkf1mFTVbreYrwn7dsYmEPjTq6QN90E= +github.com/go-webauthn/webauthn v0.8.6/go.mod h1:emwVLMCI5yx9evTTvr0r+aOZCdWJqMfbRhF0MufyUog= +github.com/go-webauthn/x v0.1.4 h1:sGmIFhcY70l6k7JIDfnjVBiAAFEssga5lXIUXe0GtAs= +github.com/go-webauthn/x v0.1.4/go.mod h1:75Ug0oK6KYpANh5hDOanfDI+dvPWHk788naJVG/37H8= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -24,12 +33,18 @@ github.com/google/go-cmp v0.5.0/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.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= +github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= @@ -50,6 +65,8 @@ github.com/tdewolff/parse/v2 v2.7.4 h1:zrUn2CFg9+5llbUZcsycctFlNRyV1D5gFBZRxuGzd github.com/tdewolff/parse/v2 v2.7.4/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52 h1:gAQliwn+zJrkjAHVcBEYW/RFvd2St4yYimisvozAYlA= github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -59,12 +76,15 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= @@ -72,6 +92,7 @@ golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -80,11 +101,13 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -93,11 +116,13 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= diff --git a/internal/config/options.go b/internal/config/options.go index 11a4ac2c..5ee4ae69 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -81,6 +81,7 @@ const ( defaultMetricsPassword = "" defaultWatchdog = true defaultInvidiousInstance = "yewtu.be" + defaultWebAuthn = true ) var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)" @@ -161,6 +162,7 @@ type Options struct { watchdog bool invidiousInstance string proxyPrivateKey []byte + webAuthn bool } // NewOptions returns Options with default values. @@ -235,6 +237,7 @@ func NewOptions() *Options { watchdog: defaultWatchdog, invidiousInstance: defaultInvidiousInstance, proxyPrivateKey: randomKey, + webAuthn: defaultWebAuthn, } } @@ -592,6 +595,11 @@ func (o *Options) ProxyPrivateKey() []byte { return o.proxyPrivateKey } +// WebAuthn returns true if WebAuthn logins are supported +func (o *Options) WebAuthn() bool { + return o.webAuthn +} + // SortedOptions returns options as a list of key value pairs, sorted by keys. func (o *Options) SortedOptions(redactSecret bool) []*Option { var keyValues = map[string]interface{}{ @@ -665,6 +673,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option { "WATCHDOG": o.watchdog, "WORKER_POOL_SIZE": o.workerPoolSize, "YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride, + "WEBAUTHN": o.webAuthn, } keys := make([]string, 0, len(keyValues)) diff --git a/internal/config/parser.go b/internal/config/parser.go index f354e5b5..454d2c99 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -244,6 +244,8 @@ func (p *Parser) parseLines(lines []string) (err error) { randomKey := make([]byte, 16) rand.Read(randomKey) p.opts.proxyPrivateKey = parseBytes(value, randomKey) + case "WEBAUTHN": + p.opts.webAuthn = parseBool(value, defaultWebAuthn) } } diff --git a/internal/database/migrations.go b/internal/database/migrations.go index ba356217..8931772b 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -807,4 +807,22 @@ var migrations = []func(tx *sql.Tx) error{ _, err = tx.Exec(sql) return }, + func(tx *sql.Tx) (err error) { + _, err = tx.Exec(` + CREATE TABLE webauthn_credentials ( + handle bytea primary key, + cred_id bytea unique not null, + user_id int references users(id) on delete cascade not null, + public_key bytea not null, + attestation_type varchar(255) not null, + aaguid bytea, + sign_count bigint, + clone_warning bool, + name text, + added_on timestamp with time zone default now(), + last_seen_on timestamp with time zone default now() + ); + `) + return + }, } diff --git a/internal/http/request/context.go b/internal/http/request/context.go index 9a0acbf4..b2ce54cf 100644 --- a/internal/http/request/context.go +++ b/internal/http/request/context.go @@ -6,6 +6,8 @@ package request // import "miniflux.app/v2/internal/http/request" import ( "net/http" "strconv" + + "miniflux.app/v2/internal/model" ) // ContextKey represents a context key. @@ -30,8 +32,22 @@ const ( LastForceRefreshContextKey ClientIPContextKey GoogleReaderToken + WebAuthnDataContextKey ) +func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession { + if v := r.Context().Value(WebAuthnDataContextKey); v != nil { + value, valid := v.(model.WebAuthnSession) + if !valid { + return nil + } + + return &value + } + + return nil +} + // GoolgeReaderToken returns the google reader token if it exists. func GoolgeReaderToken(r *http.Request) string { return getContextStringValue(r, GoogleReaderToken) diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json index e517bb30..4f45f1a6 100644 --- a/internal/locale/translations/de_DE.json +++ b/internal/locale/translations/de_DE.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Google Konto Verknüpfung entfernen", "page.settings.link_oidc_account": "OpenID Connect Konto verknüpfen", "page.settings.unlink_oidc_account": "OpenID Connect Konto Verknüpfung entfernen", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Hauptschlüssel registrieren", + "page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden", + "page.settings.webauthn.delete": [ + "Entfernen Sie %d Hauptschlüssel", + "%d Hauptschlüssel entfernen" + ], "page.login.title": "Anmeldung", "page.login.google_signin": "Anmeldung mit Google", "page.login.oidc_signin": "Anmeldung mit OpenID Connect", + "page.login.webauthn_login": "Melden Sie sich mit dem Hauptschlüssel an", + "page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich", "page.integrations.title": "Dienste", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "API Endpunkt", @@ -210,6 +223,7 @@ "page.offline.title": "Offline-Modus", "page.offline.message": "Du bist offline", "page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.", "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.", "alert.no_category": "Es ist keine Kategorie vorhanden.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json index 4acb9b51..b084f16c 100644 --- a/internal/locale/translations/el_EL.json +++ b/internal/locale/translations/el_EL.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google", "page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου OpenID Connect", "page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου OpenID Connect", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης", + "page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης", + "page.settings.webauthn.delete": [ + "Αφαιρέστε %d κωδικό πρόσβασης", + "Καταργήστε %d κωδικούς πρόσβασης" + ], "page.login.title": "Είσοδος", "page.login.google_signin": "Συνδεθείτε με τo Google", "page.login.oidc_signin": "Συνδεθείτε με το OpenID Connect", + "page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης", + "page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης", "page.integrations.title": "Ενσωμάτωση", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "Τελικό σημείο API", @@ -210,6 +223,7 @@ "page.offline.title": "Λειτουργία Εκτός Σύνδεσης", "page.offline.message": "Είστε εκτός σύνδεσης", "page.offline.refresh_page": "Προσπαθήστε να ανανεώσετε τη σελίδα", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Δεν υπάρχει κοινόχρηστη καταχώρηση.", "alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.", "alert.no_category": "Δεν υπάρχει κατηγορία.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json index 4370c470..ceed8a8f 100644 --- a/internal/locale/translations/en_US.json +++ b/internal/locale/translations/en_US.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Unlink my Google account", "page.settings.link_oidc_account": "Link my OpenID Connect account", "page.settings.unlink_oidc_account": "Unlink my OpenID Connect account", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Register passkey", + "page.settings.webauthn.register.error": "Unable to register passkey", + "page.settings.webauthn.delete" : [ + "Remove %d passkey", + "Remove %d passkeys" + ], "page.login.title": "Sign In", "page.login.google_signin": "Sign in with Google", "page.login.oidc_signin": "Sign in with OpenID Connect", + "page.login.webauthn_login": "Login with passkey", + "page.login.webauthn_login.error": "Unable to login with passkey", "page.integrations.title": "Integrations", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "API Endpoint", @@ -210,6 +223,7 @@ "page.offline.title": "Offline Mode", "page.offline.message": "You are offline", "page.offline.refresh_page": "Try to refresh the page", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "There is no shared entry.", "alert.no_bookmark": "There is no bookmark at the moment.", "alert.no_category": "There is no category.", diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json index 3aaa480e..bc1ab9a8 100644 --- a/internal/locale/translations/es_ES.json +++ b/internal/locale/translations/es_ES.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Desvincular mi cuenta de Google", "page.settings.link_oidc_account": "Vincular mi cuenta de OpenID Connect", "page.settings.unlink_oidc_account": "Desvincular mi cuenta de OpenID Connect", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Registrar clave de acceso", + "page.settings.webauthn.register.error": "No se puede registrar la clave de paso", + "page.settings.webauthn.delete": [ + "Eliminar %d clave de paso", + "Eliminar %d claves de paso" + ], "page.login.title": "Iniciar sesión", "page.login.google_signin": "Iniciar sesión con tu cuenta de Google", "page.login.oidc_signin": "Iniciar sesión con tu cuenta de OpenID Connect", + "page.login.webauthn_login": "Iniciar sesión con clave de acceso", + "page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de paso", "page.integrations.title": "Integraciones", "page.integration.miniflux_api": "API de Miniflux", "page.integration.miniflux_api_endpoint": "Extremo de API", @@ -210,6 +223,7 @@ "page.offline.title": "Modo offline", "page.offline.message": "Estas desconectado", "page.offline.refresh_page": "Intenta actualizar la página", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "No hay artículos compartidos.", "alert.no_bookmark": "No hay marcador en este momento.", "alert.no_category": "No hay categoría.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json index 51b5b5e3..b9802d99 100644 --- a/internal/locale/translations/fi_FI.json +++ b/internal/locale/translations/fi_FI.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Poista Google-tilini linkitys", "page.settings.link_oidc_account": "Linkitä OpenID Connect -tilini", "page.settings.unlink_oidc_account": "Poista OpenID Connect -tilini linkitys", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Rekisteröi salasana", + "page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä", + "page.settings.webauthn.delete": [ + "Poista %d salasana", + "Poista %d salasanaa" + ], "page.login.title": "Kirjaudu sisään", "page.login.google_signin": "Kirjaudu sisään Googlella", "page.login.oidc_signin": "Kirjaudu sisään OpenID Connectilla", + "page.login.webauthn_login": "Kirjaudu sisään salasanalla", + "page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla", "page.integrations.title": "Integraatiot", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "API-päätepiste", @@ -210,6 +223,7 @@ "page.offline.title": "Offline-tila", "page.offline.message": "Olet offline-tilassa", "page.offline.refresh_page": "Yritä päivittää sivu", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Jaettua artikkelia ei ole.", "alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.", "alert.no_category": "Ei ole kategoriaa.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json index 7fab4d19..c27566ac 100644 --- a/internal/locale/translations/fr_FR.json +++ b/internal/locale/translations/fr_FR.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Dissocier mon compte Google", "page.settings.link_oidc_account": "Associer mon compte OpenID Connect", "page.settings.unlink_oidc_account": "Dissocier mon compte OpenID Connect", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Register passkey", + "page.settings.webauthn.register.error": "Unable to register passkey", + "page.settings.webauthn.delete" : [ + "Remove %d passkey", + "Remove %d passkeys" + ], "page.login.title": "Connexion", "page.login.google_signin": "Se connecter avec Google", "page.login.oidc_signin": "Se connecter avec OpenID Connect", + "page.login.webauthn_login": "Login with passkey", + "page.login.webauthn_login.error": "Unable to login with passkey", "page.integrations.title": "Intégrations", "page.integration.miniflux_api": "API de Miniflux", "page.integration.miniflux_api_endpoint": "Point de terminaison de l'API", @@ -210,6 +223,7 @@ "page.offline.title": "Mode Hors-Ligne", "page.offline.message": "Vous n'êtes pas connecté", "page.offline.refresh_page": "Essayez de rafraîchir la page", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Il n'y a pas d'article partagé.", "alert.no_bookmark": "Il n'y a aucun favoris pour le moment.", "alert.no_category": "Il n'y a aucune catégorie.", @@ -462,4 +476,4 @@ "error.feed_not_found": "Impossible de trouver ce flux.", "error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.", "error.feed_format_not_detected": "Impossible de détecter le format du flux : %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json index 616a30ee..a2961051 100644 --- a/internal/locale/translations/hi_IN.json +++ b/internal/locale/translations/hi_IN.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "मेरा गूगल खाता हटाय", "page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय", "page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "रजिस्टर पासकी", + "page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ", + "page.settings.webauthn.delete": [ + "%d पासकुंजी निकालें", + "%d पासकी हटाएं" + ], "page.login.title": "साइन इन करें", "page.login.google_signin": "गूगल के साथ साइन इन करें", "page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें", + "page.login.webauthn_login": "पासकी से लॉगिन करें", + "page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ", "page.integrations.title": "एकीकरण", "page.integration.miniflux_api": "मिनिफलक्ष एपीआई", "page.integration.miniflux_api_endpoint": "एपीआई समापन बिंदु", @@ -210,6 +223,7 @@ "page.offline.title": "ऑफ़लाइन मोड", "page.offline.message": "आप संपर्क में नहीं हैं", "page.offline.refresh_page": "पृष्ठ को ताज़ा करने का प्रयास करें", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "कोई साझा प्रविष्टि नहीं है", "alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है", "alert.no_category": "कोई श्रेणी नहीं है।", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json index 0f66934b..fc453509 100644 --- a/internal/locale/translations/id_ID.json +++ b/internal/locale/translations/id_ID.json @@ -177,9 +177,22 @@ "page.settings.unlink_google_account": "Putuskan akun Google saya", "page.settings.link_oidc_account": "Tautkan akun OpenID Connect saya", "page.settings.unlink_oidc_account": "Putuskan akun OpenID Connect saya", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Register passkey", + "page.settings.webauthn.register.error": "Unable to register passkey", + "page.settings.webauthn.delete": [ + "Remove %d passkey", + "Remove %d passkeys" + ], "page.login.title": "Masuk", "page.login.google_signin": "Masuk dengan Google", "page.login.oidc_signin": "Masuk dengan OpenID Connect", + "page.login.webauthn_login": "Login with passkey", + "page.login.webauthn_login.error": "Unable to login with passkey", "page.integrations.title": "Integrasi", "page.integration.miniflux_api": "API Miniflux", "page.integration.miniflux_api_endpoint": "Titik URL API", @@ -207,6 +220,7 @@ "page.offline.title": "Mode Luring", "page.offline.message": "Anda sedang luring", "page.offline.refresh_page": "Coba untuk memuat ulang halaman ini", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Tidak ada entri yang dibagikan.", "alert.no_bookmark": "Tidak ada markah.", "alert.no_category": "Tidak ada kategori.", @@ -453,4 +467,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json index 56fc0931..fba0b9e2 100644 --- a/internal/locale/translations/it_IT.json +++ b/internal/locale/translations/it_IT.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Scollega il mio account Google", "page.settings.link_oidc_account": "Collega il mio account OpenID Connect", "page.settings.unlink_oidc_account": "Scollega il mio account OpenID Connect", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Registra la chiave di accesso", + "page.settings.webauthn.register.error": "Impossibile registrare la passkey", + "page.settings.webauthn.delete": [ + "Rimuovi %d passkey", + "Rimuovi %d passkey" + ], "page.login.title": "Accedi", "page.login.google_signin": "Accedi tramite Google", "page.login.oidc_signin": "Accedi tramite OpenID Connect", + "page.login.webauthn_login": "Accedi con passkey", + "page.login.webauthn_login.error": "Impossibile accedere con passkey", "page.integrations.title": "Integrazioni", "page.integration.miniflux_api": "API di Miniflux", "page.integration.miniflux_api_endpoint": "Endpoint dell'API di Miniflux", @@ -210,6 +223,7 @@ "page.offline.title": "Modalità offline", "page.offline.message": "Sei offline", "page.offline.refresh_page": "Prova ad aggiornare la pagina", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Non ci sono voci condivise.", "alert.no_bookmark": "Nessun preferito disponibile.", "alert.no_category": "Nessuna categoria disponibile.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json index 6690736f..15b5f671 100644 --- a/internal/locale/translations/ja_JP.json +++ b/internal/locale/translations/ja_JP.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Google アカウントと接続を解除する", "page.settings.link_oidc_account": "OpenID Connect アカウントと接続する", "page.settings.unlink_oidc_account": "OpenID Connect アカウントと接続を解除する", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "パスキーを登録する", + "page.settings.webauthn.register.error": "パスキーを登録できません", + "page.settings.webauthn.delete": [ + "%d 個のパスキーを削除", + "%d 個のパスキーを削除" + ], "page.login.title": "ログイン", "page.login.google_signin": "Google アカウントでログイン", "page.login.oidc_signin": "OpenID Connect アカウントでログイン", + "page.login.webauthn_login": "パスキーでログイン", + "page.login.webauthn_login.error": "パスキーでログインできない", "page.integrations.title": "連携", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "API Endpoint", @@ -210,6 +223,7 @@ "page.offline.title": "オフラインモード", "page.offline.message": "オフラインです", "page.offline.refresh_page": "ページを更新してみてください", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "共有エントリはありません。", "alert.no_bookmark": "現在星付きはありません。", "alert.no_category": "カテゴリが存在しません。", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json index f50f5023..93a37850 100644 --- a/internal/locale/translations/nl_NL.json +++ b/internal/locale/translations/nl_NL.json @@ -181,7 +181,20 @@ "page.settings.unlink_google_account": "Ontkoppel mijn Google-account", "page.settings.link_oidc_account": "Koppel mijn OpenID Connect-account", "page.settings.unlink_oidc_account": "Ontkoppel mijn OpenID Connect-account", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Wachtwoord registreren", + "page.settings.webauthn.register.error": "Kan wachtwoord niet registreren", + "page.settings.webauthn.delete": [ + "Verwijder %d wachtwoord", + "Verwijder %d wachtwoordsleutels" + ], "page.login.oidc_signin": "Inloggen via OpenID Connect", + "page.login.webauthn_login": "Inloggen met wachtwoord", + "page.login.webauthn_login.error": "Kan niet inloggen met wachtwoord", "page.login.google_signin": "Inloggen via Google", "page.integrations.title": "Integraties", "page.integration.miniflux_api": "Miniflux API", @@ -210,6 +223,7 @@ "page.offline.title": "Offline modus", "page.offline.message": "Je bent offline", "page.offline.refresh_page": "Probeer de pagina te vernieuwen", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Er is geen gedeelde toegang.", "alert.no_bookmark": "Er zijn op dit moment geen favorieten.", "alert.no_category": "Er zijn geen categorieën.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json index 22d0f164..0b64ac47 100644 --- a/internal/locale/translations/pl_PL.json +++ b/internal/locale/translations/pl_PL.json @@ -182,9 +182,23 @@ "page.settings.unlink_google_account": "Odłącz moje konto Google", "page.settings.link_oidc_account": "Połącz z moim kontem OpenID Connect", "page.settings.unlink_oidc_account": "Odłącz moje konto OpenID Connect", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Zarejestruj klucz dostępu", + "page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu", + "page.settings.webauthn.delete": [ + "Usuń %d klucz dostępu", + "Usuń %d klucze dostępu", + "Usuń %d klucze dostępu" + ], "page.login.title": "Zaloguj się", "page.login.google_signin": "Zaloguj przez Google", "page.login.oidc_signin": "Zaloguj przez OpenID Connect", + "page.login.webauthn_login": "Zaloguj się za pomocą hasła", + "page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu", "page.integrations.title": "Usługi", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "Punkt końcowy API", @@ -212,6 +226,7 @@ "page.offline.title": "Tryb offline", "page.offline.message": "Jesteś odłączony od sieci", "page.offline.refresh_page": "Spróbuj odświeżyć stronę", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Brak wspólnego wpisu.", "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.", "alert.no_category": "Nie ma żadnej kategorii!", @@ -470,4 +485,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json index 23856951..c6a432bd 100644 --- a/internal/locale/translations/pt_BR.json +++ b/internal/locale/translations/pt_BR.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Desvincular minha conta do Google", "page.settings.link_oidc_account": "Vincular minha conta do OpenID Connect", "page.settings.unlink_oidc_account": "Desvincular minha conta do OpenID Connect", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Registrar senha", + "page.settings.webauthn.register.error": "Não foi possível registrar a senha", + "page.settings.webauthn.delete": [ + "Remover %d senha", + "Remover %d senhas" + ], "page.login.title": "Iniciar Sessão", "page.login.google_signin": "Iniciar Sessão com sua conta do Google", "page.login.oidc_signin": "Iniciar Sessão com sua conta do OpenID Connect", + "page.login.webauthn_login": "Entrar com senha", + "page.login.webauthn_login.error": "Não é possível fazer login com senha", "page.integrations.title": "Integrações", "page.integration.miniflux_api": "API do Miniflux", "page.integration.miniflux_api_endpoint": "Endpoint da API", @@ -210,6 +223,7 @@ "page.offline.title": "Modo offline", "page.offline.message": "Você está offline", "page.offline.refresh_page": "Tente atualizar a página", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Não há itens compartilhados.", "alert.no_bookmark": "Não há favorito neste momento.", "alert.no_category": "Não há categoria.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json index 5e189afd..d0f05f73 100644 --- a/internal/locale/translations/ru_RU.json +++ b/internal/locale/translations/ru_RU.json @@ -182,9 +182,23 @@ "page.settings.unlink_google_account": "Отвязать мой Google аккаунт", "page.settings.link_oidc_account": "Привязать мой OpenID Connect аккаунт", "page.settings.unlink_oidc_account": "Отвязать мой OpenID Connect аккаунт", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Зарегистрировать пароль", + "page.settings.webauthn.register.error": "Не удается зарегистрировать пароль", + "page.settings.webauthn.delete": [ + "Удалить %d пароль", + "Удалить %d пароля", + "Удалить %d пароля" + ], "page.login.title": "Войти", "page.login.google_signin": "Войти с помощью Google", "page.login.oidc_signin": "Войти с помощью OpenID Connect", + "page.login.webauthn_login": "Войти с паролем", + "page.login.webauthn_login.error": "Невозможно войти с паролем", "page.integrations.title": "Интеграции", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "Конечная точка API", @@ -212,6 +226,7 @@ "page.offline.title": "Автономный режим", "page.offline.message": "Нет соединения", "page.offline.refresh_page": "Попробуйте обновить страницу", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Общедоступные статьи отсутствуют.", "alert.no_bookmark": "Избранное отсутствует.", "alert.no_category": "Категории отсутствуют.", @@ -470,4 +485,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json index 090fa144..cede44ca 100644 --- a/internal/locale/translations/tr_TR.json +++ b/internal/locale/translations/tr_TR.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır", "page.settings.link_oidc_account": "OpenID Connect hesabımı bağla", "page.settings.unlink_oidc_account": "OpenID Connect hesabımın bağlantısını kaldır", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "şifreyi kaydet", + "page.settings.webauthn.register.error": "Geçiş anahtarı kaydedilemiyor", + "page.settings.webauthn.delete": [ + "%d geçiş anahtarını kaldır", + "%d geçiş anahtarını kaldır" + ], "page.login.title": "Oturum aç", "page.login.google_signin": "Google ile oturum aç", "page.login.oidc_signin": "OpenID Connect ile oturum aç", + "page.login.webauthn_login": "şifre ile giriş yap", + "page.login.webauthn_login.error": "şifre ile giriş yapılamıyor", "page.integrations.title": "Bütünleşmeler", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "API Uç Noktası", @@ -210,6 +223,7 @@ "page.offline.title": "Çevrimdışı Modu", "page.offline.message": "Çevrimdışısınız", "page.offline.refresh_page": "Sayfayı yenilemeyi dene", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Paylaşılan ileti yok.", "alert.no_bookmark": "Şu anda hiç yer imi yok.", "alert.no_category": "Hiç kategori yok.", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json index 571307ae..63119dcc 100644 --- a/internal/locale/translations/uk_UA.json +++ b/internal/locale/translations/uk_UA.json @@ -183,9 +183,23 @@ "page.settings.unlink_google_account": "Відключити мій обліковий запис Google", "page.settings.link_oidc_account": "Підключити мій обліковий запис OpenID Connect", "page.settings.unlink_oidc_account": "Відключити мій обліковий запис OpenID Connect", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Зареєструвати пароль", + "page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу", + "page.settings.webauthn.delete": [ + "Видалити %d ключ доступу", + "Видаліть %d ключа доступу", + "Видаліть %d ключа доступу" + ], "page.login.title": "Вхід", "page.login.google_signin": "Увійти через Google", "page.login.oidc_signin": "Увійти через OpenID Connect", + "page.login.webauthn_login": "Увійти за допомогою пароля", + "page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу", "page.integrations.title": "Інтеграції", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "Адреса доступу API", @@ -213,6 +227,7 @@ "page.offline.title": "Автономний режим", "page.offline.message": "Ви офлайн", "page.offline.refresh_page": "Спробуйте оновити сторінку", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "Немає спільного запису.", "alert.no_bookmark": "Наразі закладки відсутні.", "alert.no_category": "Немає категорії.", @@ -471,4 +486,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json index 7088e649..9c4c6e82 100644 --- a/internal/locale/translations/zh_CN.json +++ b/internal/locale/translations/zh_CN.json @@ -178,9 +178,22 @@ "page.settings.unlink_google_account": "解除 Google 账号关联", "page.settings.link_oidc_account": "关联我的 OpenID Connect 账户", "page.settings.unlink_oidc_account": "解除 OpenID Connect 账号关联", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "注册密码", + "page.settings.webauthn.register.error": "无法注册密钥", + "page.settings.webauthn.delete": [ + "删除 %d 个密钥", + "删除 %d 个密钥" + ], "page.login.title": "登录", "page.login.google_signin": "使用 Google 登录", "page.login.oidc_signin": "使用 OpenID Connect 登录", + "page.login.webauthn_login": "使用密码登录", + "page.login.webauthn_login.error": "无法使用密码登录", "page.integrations.title": "集成", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "API Endpoint", @@ -208,6 +221,7 @@ "page.offline.title": "离线模式", "page.offline.message": "您已离线", "page.offline.refresh_page": "尝试刷新页面", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "没有分享文章。", "alert.no_bookmark": "目前没有收藏", "alert.no_category": "目前没有分类", @@ -454,4 +468,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json index 33f3a2a1..81e1ab64 100644 --- a/internal/locale/translations/zh_TW.json +++ b/internal/locale/translations/zh_TW.json @@ -180,9 +180,22 @@ "page.settings.unlink_google_account": "解除 Google 帳號關聯", "page.settings.link_oidc_account": "關聯我的 OpenID Connect 賬戶", "page.settings.unlink_oidc_account": "解除 OpenID Connect 帳號關聯", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "註冊密鑰", + "page.settings.webauthn.register.error": "無法註冊密鑰", + "page.settings.webauthn.delete": [ + "刪除 %d 個密碼", + "刪除 %d 個密鑰" + ], "page.login.title": "登入", "page.login.google_signin": "使用 Google 登入", "page.login.oidc_signin": "使用 OpenID Connect 登入", + "page.login.webauthn_login": "使用密碼登錄", + "page.login.webauthn_login.error": "無法使用密碼登錄", "page.integrations.title": "整合", "page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api_endpoint": "API Endpoint", @@ -210,6 +223,7 @@ "page.offline.title": "離線模式", "page.offline.message": "您已離線", "page.offline.refresh_page": "嘗試重新整理頁面", + "page.webauthn_rename.title": "Rename Passkey", "alert.no_shared_entry": "沒有分享文章。", "alert.no_bookmark": "目前沒有收藏", "alert.no_category": "目前沒有分類", @@ -462,4 +476,4 @@ "error.feed_not_found": "This feed does not exist or does not belong to this user.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v." -} +} \ No newline at end of file diff --git a/internal/model/app_session.go b/internal/model/app_session.go index a2fed4c1..9d4f7469 100644 --- a/internal/model/app_session.go +++ b/internal/model/app_session.go @@ -12,19 +12,20 @@ import ( // SessionData represents the data attached to the session. type SessionData struct { - CSRF string `json:"csrf"` - OAuth2State string `json:"oauth2_state"` - OAuth2CodeVerifier string `json:"oauth2_code_verifier"` - FlashMessage string `json:"flash_message"` - FlashErrorMessage string `json:"flash_error_message"` - Language string `json:"language"` - Theme string `json:"theme"` - PocketRequestToken string `json:"pocket_request_token"` - LastForceRefresh string `json:"last_force_refresh"` + CSRF string `json:"csrf"` + OAuth2State string `json:"oauth2_state"` + OAuth2CodeVerifier string `json:"oauth2_code_verifier"` + FlashMessage string `json:"flash_message"` + FlashErrorMessage string `json:"flash_error_message"` + Language string `json:"language"` + Theme string `json:"theme"` + PocketRequestToken string `json:"pocket_request_token"` + LastForceRefresh string `json:"last_force_refresh"` + WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"` } func (s SessionData) String() string { - return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, PocketTkn=%q, LastForceRefresh=%s`, + return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, PocketTkn=%q, LastForceRefresh=%s, WebAuthnSession=%q`, s.CSRF, s.OAuth2State, s.OAuth2CodeVerifier, @@ -34,6 +35,7 @@ func (s SessionData) String() string { s.Theme, s.PocketRequestToken, s.LastForceRefresh, + s.WebAuthnSessionData, ) } diff --git a/internal/model/webauthn.go b/internal/model/webauthn.go new file mode 100644 index 00000000..9d9bddf7 --- /dev/null +++ b/internal/model/webauthn.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model // import "miniflux.app/v2/internal/model" + +import ( + "database/sql/driver" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +// handle marshalling / unmarshalling session data +type WebAuthnSession struct { + *webauthn.SessionData +} + +func (s WebAuthnSession) Value() (driver.Value, error) { + return json.Marshal(s) +} + +func (s *WebAuthnSession) Scan(value interface{}) error { + b, ok := value.([]byte) + if !ok { + return errors.New("type assertion to []byte failed") + } + + return json.Unmarshal(b, &s) +} + +func (s WebAuthnSession) String() string { + if s.SessionData == nil { + return "{}" + } + return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.SessionData.Challenge, s.SessionData.UserID) +} + +type WebAuthnCredential struct { + Credential webauthn.Credential + Name string + AddedOn *time.Time + LastSeenOn *time.Time + Handle []byte +} + +func (s WebAuthnCredential) HandleEncoded() string { + return hex.EncodeToString(s.Handle) +} diff --git a/internal/storage/session.go b/internal/storage/session.go index fb78ea02..cf48b5aa 100644 --- a/internal/storage/session.go +++ b/internal/storage/session.go @@ -70,6 +70,23 @@ func (s *Storage) UpdateAppSessionField(sessionID, field string, value any) erro return nil } +func (s *Storage) UpdateAppSessionObjectField(sessionID, field string, value interface{}) error { + query := ` + UPDATE + sessions + SET + data = jsonb_set(data, '{%s}', $1, true) + WHERE + id=$2 + ` + _, err := s.db.Exec(fmt.Sprintf(query, field), value, sessionID) + if err != nil { + return fmt.Errorf(`store: unable to update session field: %v`, err) + } + + return nil +} + // AppSession returns the given session. func (s *Storage) AppSession(id string) (*model.Session, error) { var session model.Session diff --git a/internal/storage/webauthn.go b/internal/storage/webauthn.go new file mode 100644 index 00000000..80f51a2d --- /dev/null +++ b/internal/storage/webauthn.go @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package storage // import "miniflux.app/v2/internal/storage" + +import ( + "database/sql" + "fmt" + "log/slog" + + "github.com/go-webauthn/webauthn/webauthn" + "miniflux.app/v2/internal/model" +) + +// handle storage of webauthn credentials +func (s *Storage) AddWebAuthnCredential(userID int64, handle []byte, credential *webauthn.Credential) error { + query := ` + INSERT INTO webauthn_credentials + (handle, cred_id, user_id, public_key, attestation_type, aaguid, sign_count, clone_warning) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8) + ` + _, err := s.db.Exec( + query, + handle, + credential.ID, + userID, + credential.PublicKey, + credential.AttestationType, + credential.Authenticator.AAGUID, + credential.Authenticator.SignCount, + credential.Authenticator.CloneWarning, + ) + return err +} + +func (s *Storage) WebAuthnCredentialByHandle(handle []byte) (int64, *model.WebAuthnCredential, error) { + var credential model.WebAuthnCredential + var userID int64 + query := ` + SELECT + user_id, + cred_id, + public_key, + attestation_type, + aaguid, + sign_count, + clone_warning, + added_on, + last_seen_on, + name + FROM + webauthn_credentials + WHERE + handle = $1 + ` + var nullName sql.NullString + err := s.db. + QueryRow(query, handle). + Scan( + &userID, + &credential.Credential.ID, + &credential.Credential.PublicKey, + &credential.Credential.AttestationType, + &credential.Credential.Authenticator.AAGUID, + &credential.Credential.Authenticator.SignCount, + &credential.Credential.Authenticator.CloneWarning, + &credential.AddedOn, + &credential.LastSeenOn, + &nullName, + ) + + if err != nil { + return 0, nil, err + } + + if nullName.Valid { + credential.Name = nullName.String + } else { + credential.Name = "" + } + credential.Handle = handle + return userID, &credential, err +} + +func (s *Storage) WebAuthnCredentialsByUserID(userID int64) ([]model.WebAuthnCredential, error) { + query := ` + SELECT + handle, + cred_id, + public_key, + attestation_type, + aaguid, + sign_count, + clone_warning, + name, + added_on, + last_seen_on + FROM + webauthn_credentials + WHERE + user_id = $1 + ` + rows, err := s.db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var creds []model.WebAuthnCredential + var nullName sql.NullString + for rows.Next() { + var cred model.WebAuthnCredential + err = rows.Scan( + &cred.Handle, + &cred.Credential.ID, + &cred.Credential.PublicKey, + &cred.Credential.AttestationType, + &cred.Credential.Authenticator.AAGUID, + &cred.Credential.Authenticator.SignCount, + &cred.Credential.Authenticator.CloneWarning, + &nullName, + &cred.AddedOn, + &cred.LastSeenOn, + ) + if err != nil { + return nil, err + } + + if nullName.Valid { + cred.Name = nullName.String + } else { + cred.Name = "" + } + + creds = append(creds, cred) + } + return creds, nil +} + +func (s *Storage) WebAuthnSaveLogin(handle []byte) error { + query := "UPDATE webauthn_credentials SET last_seen_on=NOW() WHERE handle=$1" + _, err := s.db.Exec(query, handle) + if err != nil { + return fmt.Errorf(`store: unable to update last seen date for webauthn credential: %v`, err) + } + return nil +} + +func (s *Storage) WebAuthnUpdateName(handle []byte, name string) error { + query := "UPDATE webauthn_credentials SET name=$1 WHERE handle=$2" + _, err := s.db.Exec(query, name, handle) + if err != nil { + return fmt.Errorf(`store: unable to update name for webauthn credential: %v`, err) + } + return nil +} + +func (s *Storage) CountWebAuthnCredentialsByUserID(userID int64) int { + var count int + query := "SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = $1" + err := s.db.QueryRow(query, userID).Scan(&count) + if err != nil { + slog.Error("store: unable to count webauthn certs for user", + slog.Int64("user_id", userID), + slog.Any("error", err), + ) + return 0 + } + return count +} + +func (s *Storage) DeleteCredentialByHandle(userID int64, handle []byte) error { + query := "DELETE FROM webauthn_credentials WHERE user_id = $1 AND handle = $2" + _, err := s.db.Exec(query, userID, handle) + return err +} + +func (s *Storage) DeleteAllWebAuthnCredentialsByUserID(userID int64) error { + query := "DELETE FROM webauthn_credentials WHERE user_id = $1" + _, err := s.db.Exec(query, userID) + return err +} diff --git a/internal/template/templates/common/layout.html b/internal/template/templates/common/layout.html index 9ad15a6d..47d5bd0e 100644 --- a/internal/template/templates/common/layout.html +++ b/internal/template/templates/common/layout.html @@ -44,12 +44,22 @@ + {{ if .webAuthnEnabled }} + + {{ end }} {{ if .user }} diff --git a/internal/template/templates/views/login.html b/internal/template/templates/views/login.html index 74ed39c9..8d611f4b 100644 --- a/internal/template/templates/views/login.html +++ b/internal/template/templates/views/login.html @@ -19,6 +19,14 @@ + {{ if .webAuthnEnabled }} + +
+ +
+ {{ end }} {{ if hasOAuth2Provider "google" }}
{{ t "page.login.google_signin" }} diff --git a/internal/template/templates/views/settings.html b/internal/template/templates/views/settings.html index 43ded00d..71f6c602 100644 --- a/internal/template/templates/views/settings.html +++ b/internal/template/templates/views/settings.html @@ -46,7 +46,56 @@
+ + + + {{ if .webAuthnEnabled }} +
+ {{ t "page.settings.webauthn.passkeys" }} + +
+ + {{ if gt .countWebAuthnCerts 0}} + + {{ end }} +
+ {{ if .webAuthnCerts}} +
+ + + + + + + + {{ range .webAuthnCerts }} + + + + + + + {{ end }} +
{{ t "page.settings.webauthn.passkey_name" }}{{ t "page.settings.webauthn.added_on" }}{{ t "page.settings.webauthn.last_seen_on" }}{{ t "page.settings.webauthn.actions" }}
{{ .Name }}{{ elapsed $.user.Timezone .AddedOn }}{{ elapsed $.user.Timezone .LastSeenOn }} + {{ icon "delete" }}{{ t "action.remove" }} + {{ icon "edit" }} {{ t "action.edit" }} +
+
+ {{ end }}
+ {{ end }}
{{ t "form.prefs.fieldset.reader_settings" }} diff --git a/internal/template/templates/views/webauthn_rename.html b/internal/template/templates/views/webauthn_rename.html new file mode 100644 index 00000000..a3a2cf5f --- /dev/null +++ b/internal/template/templates/views/webauthn_rename.html @@ -0,0 +1,22 @@ +{{ define "title"}}{{ t "page.webauthn_rename.title" }}{{ end }} + +{{ define "content"}} + + +
+ + + {{ if .errorMessage }} +
{{ .errorMessage }}
+ {{ end }} + + + + +
+ +
+
+{{ end }} diff --git a/internal/ui/form/webauthn.go b/internal/ui/form/webauthn.go new file mode 100644 index 00000000..3f6a57db --- /dev/null +++ b/internal/ui/form/webauthn.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package form // import "miniflux.app/v2/internal/ui/form" + +import ( + "net/http" +) + +// WebauthnForm represents a credential rename form in the UI +type WebauthnForm struct { + Name string +} + +// NewWebauthnForm returns a new WebnauthnForm. +func NewWebauthnForm(r *http.Request) *WebauthnForm { + return &WebauthnForm{ + Name: r.FormValue("name"), + } +} diff --git a/internal/ui/middleware.go b/internal/ui/middleware.go index 7cfa5b34..d9682532 100644 --- a/internal/ui/middleware.go +++ b/internal/ui/middleware.go @@ -120,7 +120,7 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler { ctx = context.WithValue(ctx, request.UserThemeContextKey, session.Data.Theme) ctx = context.WithValue(ctx, request.PocketRequestTokenContextKey, session.Data.PocketRequestToken) ctx = context.WithValue(ctx, request.LastForceRefreshContextKey, session.Data.LastForceRefresh) - + ctx = context.WithValue(ctx, request.WebAuthnDataContextKey, session.Data.WebAuthnSessionData) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -159,7 +159,9 @@ func (m *middleware) isPublicRoute(r *http.Request) bool { "sharedEntry", "healthcheck", "offline", - "proxy": + "proxy", + "webauthnLoginBegin", + "webauthnLoginFinish": return true default: return false diff --git a/internal/ui/session/session.go b/internal/ui/session/session.go index c47a1828..ef43d6c8 100644 --- a/internal/ui/session/session.go +++ b/internal/ui/session/session.go @@ -6,6 +6,7 @@ package session // import "miniflux.app/v2/internal/ui/session" import ( "time" + "miniflux.app/v2/internal/model" "miniflux.app/v2/internal/storage" ) @@ -72,3 +73,7 @@ func (s *Session) SetTheme(theme string) { func (s *Session) SetPocketRequestToken(requestToken string) { s.store.UpdateAppSessionField(s.sessionID, "pocket_request_token", requestToken) } + +func (s *Session) SetWebAuthnSessionData(sessionData *model.WebAuthnSession) { + s.store.UpdateAppSessionObjectField(s.sessionID, "webauthn_session_data", sessionData) +} diff --git a/internal/ui/settings_show.go b/internal/ui/settings_show.go index edbf0345..96714271 100644 --- a/internal/ui/settings_show.go +++ b/internal/ui/settings_show.go @@ -52,6 +52,12 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) { return } + creds, err := h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + html.ServerError(w, r, err) + return + } + view.Set("form", settingsForm) view.Set("themes", model.Themes()) view.Set("languages", locale.AvailableLanguages()) @@ -62,6 +68,8 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) { view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) view.Set("default_home_pages", model.HomePages()) view.Set("categories_sorting_options", model.CategoriesSortingOptions()) + view.Set("countWebAuthnCerts", h.store.CountWebAuthnCredentialsByUserID(user.ID)) + view.Set("webAuthnCerts", creds) html.OK(w, r, view.Render("settings")) } diff --git a/internal/ui/static/css/common.css b/internal/ui/static/css/common.css index 4775b739..32a783b8 100644 --- a/internal/ui/static/css/common.css +++ b/internal/ui/static/css/common.css @@ -1112,4 +1112,8 @@ audio, video { .integration-form details .form-section { margin-top: 15px; -} \ No newline at end of file +} + +.hidden { + display: none; +} diff --git a/internal/ui/static/js/webauthn.js b/internal/ui/static/js/webauthn.js new file mode 100644 index 00000000..465aa49e --- /dev/null +++ b/internal/ui/static/js/webauthn.js @@ -0,0 +1,196 @@ +function isWebAuthnSupported() { + return window.PublicKeyCredential; +} + +async function isConditionalLoginSupported() { + return isWebAuthnSupported() && + window.PublicKeyCredential.isConditionalMediationAvailable && + window.PublicKeyCredential.isConditionalMediationAvailable(); +} + +// URLBase64 to ArrayBuffer +function bufferDecode(value) { + return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)); +} + +// ArrayBuffer to URLBase64 +function bufferEncode(value) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(value))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function getCsrfToken() { + let element = document.querySelector("body[data-csrf-token]"); + if (element !== null) { + return element.dataset.csrfToken; + } + return ""; +} + +async function post(urlKey, username, data) { + var url = document.body.dataset[urlKey]; + if (username) { + url += "?username=" + username; + } + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Csrf-Token": getCsrfToken() + }, + body: JSON.stringify(data), + }); +} + +async function get(urlKey, username) { + var url = document.body.dataset[urlKey]; + if (username) { + url += "?username=" + username; + } + return fetch(url); +} + +function showError(error) { + console.log("webauthn error: " + error); + let alert = document.getElementById("webauthn-error"); + if (alert) { + alert.classList.remove("hidden"); + } +} + +async function register() { + let beginRegisterURL = "webauthnRegisterBeginUrl"; + let r = await get(beginRegisterURL); + let credOptions = await r.json(); + credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge); + credOptions.publicKey.user.id = bufferDecode(credOptions.publicKey.user.id); + if(Object.hasOwn(credOptions.publicKey, 'excludeCredentials')) { + credOptions.publicKey.excludeCredentials.forEach((credential) => credential.id = bufferDecode(credential.id)); + } + let attestation = await navigator.credentials.create(credOptions); + let cred = { + id: attestation.id, + rawId: bufferEncode(attestation.rawId), + type: attestation.type, + response: { + attestationObject: bufferEncode(attestation.response.attestationObject), + clientDataJSON: bufferEncode(attestation.response.clientDataJSON), + }, + }; + let finishRegisterURL = "webauthnRegisterFinishUrl"; + let response = await post(finishRegisterURL, null, cred); + if (!response.ok) { + throw new Error("Login failed with HTTP status " + response.status); + } + console.log("registration successful"); + + let jsonData = await response.json(); + let redirect = jsonData.redirect; + window.location.href = redirect; +} + +async function login(username, conditional) { + let beginLoginURL = "webauthnLoginBeginUrl"; + let r = await get(beginLoginURL, username); + let credOptions = await r.json(); + credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge); + if(Object.hasOwn(credOptions.publicKey, 'allowCredentials')) { + credOptions.publicKey.allowCredentials.forEach((credential) => credential.id = bufferDecode(credential.id)); + } + if (conditional) { + credOptions.signal = abortController.signal; + credOptions.mediation = "conditional"; + } + + var assertion; + try { + assertion = await navigator.credentials.get(credOptions); + } + catch (err) { + // swallow aborted conditional logins + if (err instanceof DOMException && err.name == "AbortError") { + return; + } + throw err; + } + + if (!assertion) { + return; + } + + let assertionResponse = { + id: assertion.id, + rawId: bufferEncode(assertion.rawId), + type: assertion.type, + response: { + authenticatorData: bufferEncode(assertion.response.authenticatorData), + clientDataJSON: bufferEncode(assertion.response.clientDataJSON), + signature: bufferEncode(assertion.response.signature), + userHandle: bufferEncode(assertion.response.userHandle), + }, + }; + + let finishLoginURL = "webauthnLoginFinishUrl"; + let response = await post(finishLoginURL, username, assertionResponse); + if (!response.ok) { + throw new Error("Login failed with HTTP status " + response.status); + } + window.location.reload(); +} + +async function conditionalLogin() { + if (await isConditionalLoginSupported()) { + login("", true); + } +} + +async function removeCreds(event) { + event.preventDefault(); + let removeCredsURL = "webauthnDeleteAllUrl"; + await post(removeCredsURL, null, {}); + window.location.reload(); +} + +let abortController = new AbortController(); +document.addEventListener("DOMContentLoaded", function () { + if (!isWebAuthnSupported()) { + return; + } + + let registerButton = document.getElementById("webauthn-register"); + if (registerButton != null) { + registerButton.disabled = false; + registerButton.addEventListener("click", (e) => { + e.preventDefault(); + register().catch((err) => showError(err)); + }); + } + + let removeCredsButton = document.getElementById("webauthn-delete"); + if (removeCredsButton != null) { + removeCredsButton.addEventListener("click", removeCreds); + } + + let loginButton = document.getElementById("webauthn-login"); + if (loginButton != null) { + loginButton.disabled = false; + let usernameField = document.getElementById("form-username"); + if (usernameField != null) { + usernameField.autocomplete += " webauthn"; + } + let passwordField = document.getElementById("form-password"); + if (passwordField != null) { + passwordField.autocomplete += " webauthn"; + } + + loginButton.addEventListener("click", (e) => { + e.preventDefault(); + abortController.abort(); + login(usernameField.value).catch(err => showError(err)); + }); + + conditionalLogin().catch(err => showError(err)); + } +}); diff --git a/internal/ui/static/static.go b/internal/ui/static/static.go index 5ea2a263..bcc40fce 100644 --- a/internal/ui/static/static.go +++ b/internal/ui/static/static.go @@ -123,6 +123,9 @@ func GenerateJavascriptBundles() error { "service-worker": { "js/service_worker.js", }, + "webauthn": { + "js/webauthn.js", + }, } var prefixes = map[string]string{ diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 64149658..f95d6ebf 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -151,6 +151,16 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { uiRouter.HandleFunc("/logout", handler.logout).Name("logout").Methods(http.MethodGet) uiRouter.Handle("/", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage))).Name("login").Methods(http.MethodGet) + // WebAuthn flow + uiRouter.HandleFunc("/webauthn/register/begin", handler.beginRegistration).Name("webauthnRegisterBegin").Methods(http.MethodGet) + uiRouter.HandleFunc("/webauthn/register/finish", handler.finishRegistration).Name("webauthnRegisterFinish").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/login/begin", handler.beginLogin).Name("webauthnLoginBegin").Methods(http.MethodGet) + uiRouter.HandleFunc("/webauthn/login/finish", handler.finishLogin).Name("webauthnLoginFinish").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/deleteall", handler.deleteAllCredentials).Name("webauthnDeleteAll").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/{credentialHandle}/delete", handler.deleteCredential).Name("webauthnDelete").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/{credentialHandle}/rename", handler.renameCredential).Name("webauthnRename").Methods(http.MethodGet) + uiRouter.HandleFunc("/webauthn/{credentialHandle}/save", handler.saveCredential).Name("webauthnSave").Methods(http.MethodPost) + router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Write([]byte("User-agent: *\nDisallow: /")) diff --git a/internal/ui/view/view.go b/internal/ui/view/view.go index 1720cfc5..077340b5 100644 --- a/internal/ui/view/view.go +++ b/internal/ui/view/view.go @@ -6,6 +6,7 @@ package view // import "miniflux.app/v2/internal/ui/view" import ( "net/http" + "miniflux.app/v2/internal/config" "miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/template" "miniflux.app/v2/internal/ui/session" @@ -43,5 +44,7 @@ func New(tpl *template.Engine, r *http.Request, sess *session.Session) *View { b.params["theme_checksum"] = static.StylesheetBundleChecksums[theme] b.params["app_js_checksum"] = static.JavascriptBundleChecksums["app"] b.params["sw_js_checksum"] = static.JavascriptBundleChecksums["service-worker"] + b.params["webauthn_js_checksum"] = static.JavascriptBundleChecksums["webauthn"] + b.params["webAuthnEnabled"] = config.Opts.WebAuthn() return b } diff --git a/internal/ui/webauthn.go b/internal/ui/webauthn.go new file mode 100644 index 00000000..0071c74c --- /dev/null +++ b/internal/ui/webauthn.go @@ -0,0 +1,395 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package ui // import "miniflux.app/v2/internal/ui" + +import ( + "bytes" + "encoding/hex" + "fmt" + "log/slog" + "net/http" + "net/url" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "miniflux.app/v2/internal/config" + "miniflux.app/v2/internal/crypto" + "miniflux.app/v2/internal/http/cookie" + "miniflux.app/v2/internal/http/request" + "miniflux.app/v2/internal/http/response/html" + "miniflux.app/v2/internal/http/response/json" + "miniflux.app/v2/internal/http/route" + "miniflux.app/v2/internal/model" + "miniflux.app/v2/internal/ui/form" + "miniflux.app/v2/internal/ui/session" + "miniflux.app/v2/internal/ui/view" +) + +type WebAuthnUser struct { + User *model.User + AuthnID []byte + Credentials []model.WebAuthnCredential +} + +func (u WebAuthnUser) WebAuthnID() []byte { + return u.AuthnID +} + +func (u WebAuthnUser) WebAuthnName() string { + return u.User.Username +} + +func (u WebAuthnUser) WebAuthnDisplayName() string { + return u.User.Username +} + +func (u WebAuthnUser) WebAuthnIcon() string { + return "" +} + +func (u WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + creds := make([]webauthn.Credential, len(u.Credentials)) + for i, cred := range u.Credentials { + creds[i] = cred.Credential + } + return creds +} + +func newWebAuthn(h *handler) (*webauthn.WebAuthn, error) { + url, err := url.Parse(config.Opts.BaseURL()) + if err != nil { + return nil, err + } + return webauthn.New(&webauthn.Config{ + RPDisplayName: "Miniflux", + RPID: url.Hostname(), + RPOrigin: config.Opts.RootURL(), + }) +} + +func (h *handler) beginRegistration(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + uid := request.UserID(r) + if uid == 0 { + json.Unauthorized(w, r) + return + } + user, err := h.store.UserByID(uid) + if err != nil { + json.ServerError(w, r, err) + return + } + var creds []model.WebAuthnCredential + + creds, err = h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + json.ServerError(w, r, err) + return + } + + credsDescriptors := make([]protocol.CredentialDescriptor, len(creds)) + for i, cred := range creds { + credsDescriptors[i] = cred.Credential.Descriptor() + } + + options, sessionData, err := web.BeginRegistration( + WebAuthnUser{ + user, + crypto.GenerateRandomBytes(32), + nil, + }, + webauthn.WithExclusions(credsDescriptors), + ) + + if err != nil { + json.ServerError(w, r, err) + return + } + s := session.New(h.store, request.SessionID(r)) + s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData}) + json.OK(w, r, options) +} + +func (h *handler) finishRegistration(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + uid := request.UserID(r) + if uid == 0 { + json.Unauthorized(w, r) + return + } + user, err := h.store.UserByID(uid) + if err != nil { + json.ServerError(w, r, err) + return + } + sessionData := request.WebAuthnSessionData(r) + webAuthnUser := WebAuthnUser{user, sessionData.UserID, nil} + cred, err := web.FinishRegistration(webAuthnUser, *sessionData.SessionData, r) + if err != nil { + json.ServerError(w, r, err) + return + } + + err = h.store.AddWebAuthnCredential(uid, sessionData.UserID, cred) + if err != nil { + json.ServerError(w, r, err) + return + } + + handleEncoded := model.WebAuthnCredential{Handle: sessionData.UserID}.HandleEncoded() + redirect := route.Path(h.router, "webauthnRename", "credentialHandle", handleEncoded) + json.OK(w, r, map[string]string{"redirect": redirect}) +} + +func (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + + var user *model.User + username := request.QueryStringParam(r, "username", "") + if username != "" { + user, err = h.store.UserByUsername(username) + if err != nil { + json.Unauthorized(w, r) + return + } + } + + var assertion *protocol.CredentialAssertion + var sessionData *webauthn.SessionData + if user != nil { + creds, err := h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + json.ServerError(w, r, err) + return + } + assertion, sessionData, err = web.BeginLogin(WebAuthnUser{user, nil, creds}) + if err != nil { + json.ServerError(w, r, err) + return + } + } else { + assertion, sessionData, err = web.BeginDiscoverableLogin() + if err != nil { + json.ServerError(w, r, err) + return + } + } + + s := session.New(h.store, request.SessionID(r)) + s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData}) + json.OK(w, r, assertion) +} + +func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + + parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body) + if err != nil { + json.ServerError(w, r, err) + return + } + sessionData := request.WebAuthnSessionData(r) + + var user *model.User + username := request.QueryStringParam(r, "username", "") + if username != "" { + user, err = h.store.UserByUsername(username) + if err != nil { + json.Unauthorized(w, r) + return + } + } + + var cred *model.WebAuthnCredential + if user != nil { + creds, err := h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + json.ServerError(w, r, err) + return + } + sessionData.SessionData.UserID = parsedResponse.Response.UserHandle + credCredential, err := web.ValidateLogin(WebAuthnUser{user, parsedResponse.Response.UserHandle, creds}, *sessionData.SessionData, parsedResponse) + if err != nil { + json.Unauthorized(w, r) + return + } + + for _, credTest := range creds { + if bytes.Equal(credCredential.ID, credTest.Credential.ID) { + cred = &credTest + } + } + + if cred == nil { + json.ServerError(w, r, fmt.Errorf("no matching credential for %v", credCredential)) + return + } + } else { + userByHandle := func(rawID, userHandle []byte) (webauthn.User, error) { + var uid int64 + uid, cred, err = h.store.WebAuthnCredentialByHandle(userHandle) + if err != nil { + return nil, err + } + if uid == 0 { + return nil, fmt.Errorf("no user found for handle %x", userHandle) + } + user, err = h.store.UserByID(uid) + if err != nil { + return nil, err + } + if user == nil { + return nil, fmt.Errorf("no user found for handle %x", userHandle) + } + return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*cred}}, nil + } + + _, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData.SessionData, parsedResponse) + if err != nil { + json.Unauthorized(w, r) + return + } + } + + sessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), request.ClientIP(r)) + if err != nil { + json.ServerError(w, r, err) + return + } + + h.store.WebAuthnSaveLogin(cred.Handle) + + slog.Info("User authenticated successfully with webauthn", + slog.Bool("authentication_successful", true), + slog.String("client_ip", request.ClientIP(r)), + slog.String("user_agent", r.UserAgent()), + slog.Int64("user_id", user.ID), + slog.String("username", user.Username), + ) + h.store.SetLastLogin(user.ID) + + sess := session.New(h.store, request.SessionID(r)) + sess.SetLanguage(user.Language) + sess.SetTheme(user.Theme) + + http.SetCookie(w, cookie.New( + cookie.CookieUserSessionID, + sessionToken, + config.Opts.HTTPS, + config.Opts.BasePath(), + )) + + json.NoContent(w, r) +} + +func (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) { + sess := session.New(h.store, request.SessionID(r)) + view := view.New(h.tpl, r, sess) + + user, err := h.store.UserByID(request.UserID(r)) + if err != nil { + html.ServerError(w, r, err) + return + } + + credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") + credentialHandle, err := hex.DecodeString(credentialHandleEncoded) + if err != nil { + html.ServerError(w, r, err) + return + } + cred_uid, cred, err := h.store.WebAuthnCredentialByHandle(credentialHandle) + if err != nil { + html.ServerError(w, r, err) + return + } + + if cred_uid != user.ID { + html.Forbidden(w, r) + return + } + + webauthnForm := form.WebauthnForm{Name: cred.Name} + + view.Set("form", webauthnForm) + view.Set("cred", cred) + view.Set("menu", "settings") + view.Set("user", user) + view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) + view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) + + html.OK(w, r, view.Render("webauthn_rename")) +} + +func (h *handler) saveCredential(w http.ResponseWriter, r *http.Request) { + _, err := h.store.UserByID(request.UserID(r)) + if err != nil { + html.ServerError(w, r, err) + return + } + + credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") + credentialHandle, err := hex.DecodeString(credentialHandleEncoded) + if err != nil { + html.ServerError(w, r, err) + return + } + + newName := r.FormValue("name") + err = h.store.WebAuthnUpdateName(credentialHandle, newName) + if err != nil { + html.ServerError(w, r, err) + return + } + + html.Redirect(w, r, route.Path(h.router, "settings")) +} + +func (h *handler) deleteCredential(w http.ResponseWriter, r *http.Request) { + uid := request.UserID(r) + if uid == 0 { + json.Unauthorized(w, r) + return + } + + credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") + credentialHandle, err := hex.DecodeString(credentialHandleEncoded) + if err != nil { + json.ServerError(w, r, err) + return + } + + err = h.store.DeleteCredentialByHandle(uid, []byte(credentialHandle)) + if err != nil { + json.ServerError(w, r, err) + return + } + + json.NoContent(w, r) +} + +func (h *handler) deleteAllCredentials(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteAllWebAuthnCredentialsByUserID(request.UserID(r)) + if err != nil { + json.ServerError(w, r, err) + return + } + json.NoContent(w, r) +}