Add WebAuthn / Passkey integration
This is a rebase of #1618 in which @dave-atx added WebAuthn support. Closes #1618
This commit is contained in:
parent
62188b49f0
commit
62ef8ed57a
42 changed files with 1357 additions and 33 deletions
14
go.mod
14
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
|
||||
|
|
27
go.sum
27
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=
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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.",
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
52
internal/model/webauthn.go
Normal file
52
internal/model/webauthn.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
|
183
internal/storage/webauthn.go
Normal file
183
internal/storage/webauthn.go
Normal file
|
@ -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
|
||||
}
|
|
@ -44,12 +44,22 @@
|
|||
|
||||
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" defer></script>
|
||||
<script src="{{ route "javascript" "name" "service-worker" "checksum" .sw_js_checksum }}" defer id="service-worker-script"></script>
|
||||
{{ if .webAuthnEnabled }}
|
||||
<script src="{{ route "javascript" "name" "webauthn" "checksum" .webauthn_js_checksum }}" defer></script>
|
||||
{{ end }}
|
||||
</head>
|
||||
<body
|
||||
{{ if .csrf }}data-csrf-token="{{ .csrf }}"{{ end }}
|
||||
data-add-subscription-url="{{ route "addSubscription" }}"
|
||||
data-entries-status-url="{{ route "updateEntriesStatus" }}"
|
||||
data-refresh-all-feeds-url="{{ route "refreshAllFeeds" }}"
|
||||
{{ if .webAuthnEnabled }}
|
||||
data-webauthn-register-begin-url="{{ route "webauthnRegisterBegin" }}"
|
||||
data-webauthn-register-finish-url="{{ route "webauthnRegisterFinish" }}"
|
||||
data-webauthn-login-begin-url="{{ route "webauthnLoginBegin" }}"
|
||||
data-webauthn-login-finish-url="{{ route "webauthnLoginFinish" }}"
|
||||
data-webauthn-delete-all-url="{{ route "webauthnDeleteAll" }}"
|
||||
{{ end }}
|
||||
{{ if .user }}{{ if not .user.KeyboardShortcuts }}data-disable-keyboard-shortcuts="true"{{ end }}{{ end }}>
|
||||
|
||||
{{ if .user }}
|
||||
|
|
|
@ -19,6 +19,14 @@
|
|||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.loading" }}">{{ t "action.login" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ if .webAuthnEnabled }}
|
||||
<div class="alert alert-error hidden" id="webauthn-error">
|
||||
{{ t "page.login.webauthn_login.error" }}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button class="button" id="webauthn-login" disabled>{{ t "page.login.webauthn_login" }}</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if hasOAuth2Provider "google" }}
|
||||
<div class="oauth2">
|
||||
<a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "page.login.google_signin" }}</a>
|
||||
|
|
|
@ -46,7 +46,56 @@
|
|||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
||||
{{ if .webAuthnEnabled }}
|
||||
<fieldset>
|
||||
<legend>{{ t "page.settings.webauthn.passkeys" }}</legend>
|
||||
<div class="alert alert-error hidden" id="webauthn-error">
|
||||
{{ t "page.settings.webauthn.register.error" }}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button class="button" id="webauthn-register" disabled>
|
||||
{{ t "page.settings.webauthn.register" }}
|
||||
</button>
|
||||
{{ if gt .countWebAuthnCerts 0}}
|
||||
<button class="button" id="webauthn-delete">
|
||||
{{ plural "page.settings.webauthn.delete" .countWebAuthnCerts .countWebAuthnCerts }}
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if .webAuthnCerts}}
|
||||
<div class="details-content">
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t "page.settings.webauthn.passkey_name" }}</th>
|
||||
<th>{{ t "page.settings.webauthn.added_on" }}</th>
|
||||
<th>{{ t "page.settings.webauthn.last_seen_on" }}</th>
|
||||
<th>{{ t "page.settings.webauthn.actions" }}</th>
|
||||
</tr>
|
||||
{{ range .webAuthnCerts }}
|
||||
<tr>
|
||||
<td>{{ .Name }}</td>
|
||||
<td>{{ elapsed $.user.Timezone .AddedOn }}</td>
|
||||
<td>{{ elapsed $.user.Timezone .LastSeenOn }}</td>
|
||||
<td>
|
||||
<a href="#"
|
||||
data-confirm="true"
|
||||
data-label-question="{{ t "confirm.question" }}"
|
||||
data-label-yes="{{ t "confirm.yes" }}"
|
||||
data-label-no="{{ t "confirm.no" }}"
|
||||
data-label-loading="{{ t "confirm.loading" }}"
|
||||
data-url="{{ route "webauthnDelete" "credentialHandle" .HandleEncoded }}">{{ icon "delete" }}{{ t "action.remove" }}</a>
|
||||
<a href="{{ route "webauthnRename" "credentialHandle" .HandleEncoded }}">{{ icon "edit" }} {{ t "action.edit" }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
</fieldset>
|
||||
{{ end }}
|
||||
|
||||
<fieldset>
|
||||
<legend>{{ t "form.prefs.fieldset.reader_settings" }}</legend>
|
||||
|
|
22
internal/template/templates/views/webauthn_rename.html
Normal file
22
internal/template/templates/views/webauthn_rename.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{{ define "title"}}{{ t "page.webauthn_rename.title" }}{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
<section class="page-header">
|
||||
<h1>{{ t "page.webauthn_rename.title" }}</h1>
|
||||
</section>
|
||||
|
||||
<form action="{{ route "webauthnSave" "credentialHandle" .cred.HandleEncoded }}" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{ .csrf }}">
|
||||
|
||||
{{ if .errorMessage }}
|
||||
<div class="alert alert-error">{{ .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<label for="form-title">{{ t "page.settings.webauthn.passkey_name" }}</label>
|
||||
<input type="text" name="name" id="form-title" value="{{ .form.Name }}" autofocus>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
20
internal/ui/form/webauthn.go
Normal file
20
internal/ui/form/webauthn.go
Normal file
|
@ -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"),
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -1112,4 +1112,8 @@ audio, video {
|
|||
|
||||
.integration-form details .form-section {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
196
internal/ui/static/js/webauthn.js
Normal file
196
internal/ui/static/js/webauthn.js
Normal file
|
@ -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));
|
||||
}
|
||||
});
|
|
@ -123,6 +123,9 @@ func GenerateJavascriptBundles() error {
|
|||
"service-worker": {
|
||||
"js/service_worker.js",
|
||||
},
|
||||
"webauthn": {
|
||||
"js/webauthn.js",
|
||||
},
|
||||
}
|
||||
|
||||
var prefixes = map[string]string{
|
||||
|
|
|
@ -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: /"))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
395
internal/ui/webauthn.go
Normal file
395
internal/ui/webauthn.go
Normal file
|
@ -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)
|
||||
}
|
Loading…
Reference in a new issue