mirror of
https://github.com/thomiceli/opengist.git
synced 2024-12-22 20:42:40 +00:00
Add passkeys support + MFA (#341)
This commit is contained in:
parent
41dc2e451b
commit
6959929094
20 changed files with 1073 additions and 105 deletions
15
go.mod
15
go.mod
|
@ -9,6 +9,7 @@ require (
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-playground/validator/v10 v10.21.0
|
github.com/go-playground/validator/v10 v10.21.0
|
||||||
|
github.com/go-webauthn/webauthn v0.11.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/gorilla/sessions v1.2.2
|
github.com/gorilla/sessions v1.2.2
|
||||||
|
@ -22,8 +23,8 @@ require (
|
||||||
github.com/yuin/goldmark-emoji v1.0.2
|
github.com/yuin/goldmark-emoji v1.0.2
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
go.abhg.dev/goldmark/mermaid v0.5.0
|
go.abhg.dev/goldmark/mermaid v0.5.0
|
||||||
golang.org/x/crypto v0.23.0
|
golang.org/x/crypto v0.26.0
|
||||||
golang.org/x/text v0.15.0
|
golang.org/x/text v0.17.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.5.7
|
gorm.io/driver/mysql v1.5.7
|
||||||
gorm.io/driver/postgres v1.5.9
|
gorm.io/driver/postgres v1.5.9
|
||||||
|
@ -53,15 +54,19 @@ require (
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.12 // indirect
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
|
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.1 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
|
@ -78,6 +83,7 @@ require (
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mschoch/smat v0.2.0 // indirect
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
|
@ -88,12 +94,13 @@ require (
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
go.etcd.io/bbolt v1.3.10 // indirect
|
go.etcd.io/bbolt v1.3.10 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.25.0 // indirect
|
||||||
golang.org/x/oauth2 v0.20.0 // indirect
|
golang.org/x/oauth2 v0.20.0 // indirect
|
||||||
golang.org/x/sync v0.5.0 // indirect
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.23.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
modernc.org/libc v1.51.0 // indirect
|
modernc.org/libc v1.51.0 // indirect
|
||||||
|
|
42
go.sum
42
go.sum
|
@ -68,6 +68,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
|
@ -84,6 +86,10 @@ github.com/go-playground/validator/v10 v10.21.0 h1:4fZA11ovvtkdgaeev9RGWPgc1uj3H
|
||||||
github.com/go-playground/validator/v10 v10.21.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.21.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA=
|
||||||
|
github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE=
|
||||||
|
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
|
||||||
|
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
@ -93,6 +99,8 @@ github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
|
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
|
||||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
|
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
@ -101,6 +109,8 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
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-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||||
|
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
@ -165,6 +175,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
@ -200,6 +212,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
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/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
@ -214,30 +228,30 @@ go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW
|
||||||
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
|
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
|
||||||
go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
|
go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
|
||||||
go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
|
go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
|
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
|
||||||
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
|
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|
58
internal/auth/webauthn/user.go
Normal file
58
internal/auth/webauthn/user.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package webauthn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/thomiceli/opengist/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type user struct {
|
||||||
|
*db.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *user) WebAuthnID() []byte {
|
||||||
|
return uintToBytes(u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *user) WebAuthnName() string {
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *user) WebAuthnDisplayName() string {
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *user) WebAuthnCredentials() []webauthn.Credential {
|
||||||
|
dbCreds, err := db.GetAllWACredentialsForUser(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbCreds
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *user) Exclusions() []protocol.CredentialDescriptor {
|
||||||
|
creds := u.WebAuthnCredentials()
|
||||||
|
exclusions := make([]protocol.CredentialDescriptor, len(creds))
|
||||||
|
for i, cred := range creds {
|
||||||
|
exclusions[i] = cred.Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
return exclusions
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverUser(rawID []byte, _ []byte) (webauthn.User, error) {
|
||||||
|
ogUser, err := db.GetUserByCredentialID(rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user{User: ogUser}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uintToBytes(n uint) []byte {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(b, uint64(n))
|
||||||
|
return b
|
||||||
|
}
|
138
internal/auth/webauthn/webauthn.go
Normal file
138
internal/auth/webauthn/webauthn.go
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
package webauthn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
|
"github.com/thomiceli/opengist/internal/db"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
var webAuthn *webauthn.WebAuthn
|
||||||
|
|
||||||
|
func Init(urlStr string) error {
|
||||||
|
var rpid, rporigin string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if urlStr == "" {
|
||||||
|
log.Info().Msg("External URL is not set, passkeys RP ID and Origins will be set to localhost")
|
||||||
|
rpid = "localhost"
|
||||||
|
rporigin = "http://localhost" + ":" + config.C.HttpPort
|
||||||
|
} else {
|
||||||
|
urlStruct, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rpid = urlStruct.Hostname()
|
||||||
|
rporigin, err = protocol.FullyQualifiedOrigin(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get fully qualified origin from external URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webAuthn, err = webauthn.New(&webauthn.Config{
|
||||||
|
RPDisplayName: "Opengist",
|
||||||
|
RPID: rpid,
|
||||||
|
RPOrigins: []string{rporigin},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func BeginBinding(dbUser *db.User) (credCreation *protocol.CredentialCreation, jsonSession []byte, err error) {
|
||||||
|
waUser := &user{User: dbUser}
|
||||||
|
credCreation, session, err := webAuthn.BeginRegistration(waUser, webauthn.WithAuthenticatorSelection(
|
||||||
|
protocol.AuthenticatorSelection{
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
|
UserVerification: protocol.VerificationRequired,
|
||||||
|
},
|
||||||
|
), webauthn.WithAppIdExcludeExtension("Opengist"), webauthn.WithExclusions(waUser.Exclusions()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSession, _ = json.Marshal(session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishBinding(dbUser *db.User, jsonSession []byte, response *http.Request) (*webauthn.Credential, error) {
|
||||||
|
waUser := &user{User: dbUser}
|
||||||
|
|
||||||
|
var session webauthn.SessionData
|
||||||
|
_ = json.Unmarshal(jsonSession, &session)
|
||||||
|
|
||||||
|
return webAuthn.FinishRegistration(waUser, session, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BeginDiscoverableLogin() (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {
|
||||||
|
credCreation, session, err := webAuthn.BeginDiscoverableLogin(
|
||||||
|
webauthn.WithUserVerification(protocol.VerificationPreferred),
|
||||||
|
)
|
||||||
|
|
||||||
|
jsonSession, _ = json.Marshal(session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishDiscoverableLogin(jsonSession []byte, response *http.Request) (uint, error) {
|
||||||
|
var session webauthn.SessionData
|
||||||
|
_ = json.Unmarshal(jsonSession, &session)
|
||||||
|
|
||||||
|
parsedResponse, err := protocol.ParseCredentialRequestResponse(response)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser, cred, err := webAuthn.ValidatePasskeyLogin(discoverUser, session, parsedResponse)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbCredential, err := db.GetCredentialByID(cred.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err = dbCredential.UpdateSignCount(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err = dbCredential.UpdateLastUsedAt(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return waUser.(*user).User.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BeginLogin(dbUser *db.User) (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {
|
||||||
|
waUser := &user{User: dbUser}
|
||||||
|
credCreation, session, err := webAuthn.BeginLogin(waUser)
|
||||||
|
|
||||||
|
jsonSession, _ = json.Marshal(session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishLogin(dbUser *db.User, jsonSession []byte, response *http.Request) error {
|
||||||
|
waUser := &user{User: dbUser}
|
||||||
|
|
||||||
|
var session webauthn.SessionData
|
||||||
|
_ = json.Unmarshal(jsonSession, &session)
|
||||||
|
|
||||||
|
cred, err := webAuthn.FinishLogin(waUser, session, response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbCredential, err := db.GetCredentialByID(cred.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = dbCredential.UpdateSignCount(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = dbCredential.UpdateLastUsedAt(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ package cli
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/thomiceli/opengist/internal/auth/webauthn"
|
||||||
"github.com/thomiceli/opengist/internal/config"
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/db"
|
"github.com/thomiceli/opengist/internal/db"
|
||||||
"github.com/thomiceli/opengist/internal/git"
|
"github.com/thomiceli/opengist/internal/git"
|
||||||
|
@ -118,6 +119,10 @@ func Initialize(ctx *cli.Context) {
|
||||||
log.Fatal().Err(err).Msg("Failed to initialize in memory database")
|
log.Fatal().Err(err).Msg("Failed to initialize in memory database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := webauthn.Init(config.C.ExternalUrl); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to initialize WebAuthn")
|
||||||
|
}
|
||||||
|
|
||||||
if config.C.IndexEnabled {
|
if config.C.IndexEnabled {
|
||||||
log.Info().Msg("Index directory: " + filepath.Join(homePath, config.C.IndexDirname))
|
log.Info().Msg("Index directory: " + filepath.Join(homePath, config.C.IndexDirname))
|
||||||
index.Init(filepath.Join(homePath, config.C.IndexDirname))
|
index.Init(filepath.Join(homePath, config.C.IndexDirname))
|
||||||
|
|
|
@ -137,7 +137,7 @@ func Setup(dbUri string, sharedCache bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}); err != nil {
|
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -241,5 +241,5 @@ func DeprecationDBFilename() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TruncateDatabase() error {
|
func TruncateDatabase() error {
|
||||||
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{})
|
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{})
|
||||||
}
|
}
|
||||||
|
|
40
internal/db/types.go
Normal file
40
internal/db/types.go
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
type binaryData []byte
|
||||||
|
|
||||||
|
func (b *binaryData) Value() (driver.Value, error) {
|
||||||
|
return []byte(*b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *binaryData) Scan(value interface{}) error {
|
||||||
|
valBytes, ok := value.([]byte)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to unmarshal BinaryData: %v", value)
|
||||||
|
}
|
||||||
|
*b = valBytes
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*binaryData) GormDataType() string {
|
||||||
|
return "binary_data"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*binaryData) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
|
||||||
|
switch db.Dialector.Name() {
|
||||||
|
case "sqlite":
|
||||||
|
return "BLOB"
|
||||||
|
case "mysql":
|
||||||
|
return "VARBINARY(1024)"
|
||||||
|
case "postgres":
|
||||||
|
return "BYTEA"
|
||||||
|
default:
|
||||||
|
return "BLOB"
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,9 +18,10 @@ type User struct {
|
||||||
GiteaID string
|
GiteaID string
|
||||||
OIDCID string `gorm:"column:oidc_id"`
|
OIDCID string `gorm:"column:oidc_id"`
|
||||||
|
|
||||||
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||||
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||||
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (user *User) BeforeDelete(tx *gorm.DB) error {
|
func (user *User) BeforeDelete(tx *gorm.DB) error {
|
||||||
|
@ -58,6 +59,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = tx.Where("user_id = ?", user.ID).Delete(&WebAuthnCredential{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Delete all gists created by this user
|
// Delete all gists created by this user
|
||||||
return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
|
return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
|
||||||
}
|
}
|
||||||
|
@ -200,6 +206,13 @@ func (user *User) DeleteProviderID(provider string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (user *User) HasMFA() (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := db.Model(&WebAuthnCredential{}).Select("count(*) > 0").Where("user_id = ?", user.ID).Find(&exists).Error
|
||||||
|
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
// -- DTO -- //
|
// -- DTO -- //
|
||||||
|
|
||||||
type UserDTO struct {
|
type UserDTO struct {
|
||||||
|
|
149
internal/db/webauth_credential.go
Normal file
149
internal/db/webauth_credential.go
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebAuthnCredential struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Name string
|
||||||
|
UserID uint
|
||||||
|
User User
|
||||||
|
CredentialID binaryData `gorm:"type:binary_data"`
|
||||||
|
PublicKey binaryData `gorm:"type:binary_data"`
|
||||||
|
AttestationType string
|
||||||
|
AAGUID binaryData `gorm:"type:binary_data"`
|
||||||
|
SignCount uint32
|
||||||
|
CloneWarning bool
|
||||||
|
FlagUserPresent bool
|
||||||
|
FlagUserVerified bool
|
||||||
|
FlagBackupEligible bool
|
||||||
|
FlagBackupState bool
|
||||||
|
CreatedAt int64
|
||||||
|
LastUsedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*WebAuthnCredential) TableName() string {
|
||||||
|
return "webauthn"
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllWACredentialsForUser(userID uint) ([]webauthn.Credential, error) {
|
||||||
|
var creds []WebAuthnCredential
|
||||||
|
err := db.Where("user_id = ?", userID).Find(&creds).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webCreds := make([]webauthn.Credential, len(creds))
|
||||||
|
for i, cred := range creds {
|
||||||
|
webCreds[i] = webauthn.Credential{
|
||||||
|
ID: cred.CredentialID,
|
||||||
|
PublicKey: cred.PublicKey,
|
||||||
|
AttestationType: cred.AttestationType,
|
||||||
|
Authenticator: webauthn.Authenticator{
|
||||||
|
AAGUID: cred.AAGUID,
|
||||||
|
SignCount: cred.SignCount,
|
||||||
|
CloneWarning: cred.CloneWarning,
|
||||||
|
},
|
||||||
|
Flags: webauthn.CredentialFlags{
|
||||||
|
UserPresent: cred.FlagUserPresent,
|
||||||
|
UserVerified: cred.FlagUserVerified,
|
||||||
|
BackupEligible: cred.FlagBackupEligible,
|
||||||
|
BackupState: cred.FlagBackupState,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return webCreds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllCredentialsForUser(userID uint) ([]WebAuthnCredential, error) {
|
||||||
|
var creds []WebAuthnCredential
|
||||||
|
err := db.Where("user_id = ?", userID).Find(&creds).Error
|
||||||
|
return creds, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserByCredentialID(credID binaryData) (*User, error) {
|
||||||
|
var credential WebAuthnCredential
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch db.Dialector.Name() {
|
||||||
|
case "postgres":
|
||||||
|
hexCredID := hex.EncodeToString(credID)
|
||||||
|
if err = db.Preload("User").Where("credential_id = decode(?, 'hex')", hexCredID).First(&credential).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case "mysql":
|
||||||
|
case "sqlite":
|
||||||
|
hexCredID := hex.EncodeToString(credID)
|
||||||
|
if err = db.Preload("User").Where("credential_id = unhex(?)", hexCredID).First(&credential).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &credential.User, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCredentialByIDDB(id uint) (*WebAuthnCredential, error) {
|
||||||
|
var cred WebAuthnCredential
|
||||||
|
err := db.Where("id = ?", id).First(&cred).Error
|
||||||
|
return &cred, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCredentialByID(id binaryData) (*WebAuthnCredential, error) {
|
||||||
|
var cred WebAuthnCredential
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch db.Dialector.Name() {
|
||||||
|
case "postgres":
|
||||||
|
hexCredID := hex.EncodeToString(id)
|
||||||
|
if err = db.Where("credential_id = decode(?, 'hex')", hexCredID).First(&cred).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case "mysql":
|
||||||
|
case "sqlite":
|
||||||
|
hexCredID := hex.EncodeToString(id)
|
||||||
|
if err = db.Where("credential_id = unhex(?)", hexCredID).First(&cred).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cred, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateFromCrendential(userID uint, name string, cred *webauthn.Credential) (*WebAuthnCredential, error) {
|
||||||
|
credDb := &WebAuthnCredential{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
CredentialID: cred.ID,
|
||||||
|
PublicKey: cred.PublicKey,
|
||||||
|
AttestationType: cred.AttestationType,
|
||||||
|
AAGUID: cred.Authenticator.AAGUID,
|
||||||
|
SignCount: cred.Authenticator.SignCount,
|
||||||
|
CloneWarning: cred.Authenticator.CloneWarning,
|
||||||
|
FlagUserPresent: cred.Flags.UserPresent,
|
||||||
|
FlagUserVerified: cred.Flags.UserVerified,
|
||||||
|
FlagBackupEligible: cred.Flags.BackupEligible,
|
||||||
|
FlagBackupState: cred.Flags.BackupState,
|
||||||
|
}
|
||||||
|
err := db.Create(credDb).Error
|
||||||
|
return credDb, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebAuthnCredential) UpdateSignCount() error {
|
||||||
|
return db.Model(w).Update("sign_count", w.SignCount).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebAuthnCredential) UpdateLastUsedAt() error {
|
||||||
|
return db.Model(w).Update("last_used_at", time.Now().Unix()).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebAuthnCredential) Delete() error {
|
||||||
|
return db.Delete(w).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- DTO -- //
|
||||||
|
|
||||||
|
type CrendentialDTO struct {
|
||||||
|
PasskeyName string `json:"passkeyname" validate:"max=50"`
|
||||||
|
}
|
|
@ -143,6 +143,22 @@ auth.password: Password
|
||||||
auth.register-instead: Register instead
|
auth.register-instead: Register instead
|
||||||
auth.login-instead: Login instead
|
auth.login-instead: Login instead
|
||||||
auth.oauth: Continue with %s account
|
auth.oauth: Continue with %s account
|
||||||
|
auth.mfa: Multi-factor authentication
|
||||||
|
auth.mfa.passkey: Passkey
|
||||||
|
auth.mfa.passkeys: Passkeys
|
||||||
|
auth.mfa.use-passkey: Use passkey
|
||||||
|
auth.mfa.bind-passkey: Bind passkey
|
||||||
|
auth.mfa.login-with-passkey: Login with passkey
|
||||||
|
auth.mfa.waiting-for-passkey-input: Waiting for input from browser interaction...
|
||||||
|
auth.mfa.use-passkey-to-finish: Use a passkey to finish authentication
|
||||||
|
auth.mfa.passkeys-help: Add a passkey to log to your account and to use as an MFA method.
|
||||||
|
auth.mfa.passkey-name: Name
|
||||||
|
auth.mfa.delete-passkey: Delete
|
||||||
|
auth.mfa.passkey-added-at: Added
|
||||||
|
auth.mfa.passkey-never-used: Never used
|
||||||
|
auth.mfa.passkey-last-used: Last used
|
||||||
|
auth.mfa.delete-passkey-confirm: Confirm deletion of passkey
|
||||||
|
|
||||||
|
|
||||||
error: Error
|
error: Error
|
||||||
error.page-not-found: Page not found
|
error.page-not-found: Page not found
|
||||||
|
@ -155,6 +171,7 @@ error.oauth-unsupported: Unsupported provider
|
||||||
error.cannot-bind-data: Cannot bind data
|
error.cannot-bind-data: Cannot bind data
|
||||||
error.invalid-number: Invalid number
|
error.invalid-number: Invalid number
|
||||||
error.invalid-character-unescaped: Invalid character unescaped
|
error.invalid-character-unescaped: Invalid character unescaped
|
||||||
|
error.not-in-mfa-session: User is not in a MFA session
|
||||||
|
|
||||||
header.menu.all: All
|
header.menu.all: All
|
||||||
header.menu.new: New
|
header.menu.new: New
|
||||||
|
@ -245,6 +262,8 @@ flash.auth.account-unlinked-oauth: Account unlinked from %s
|
||||||
flash.auth.user-sshkeys-not-retrievable: Could not get user keys
|
flash.auth.user-sshkeys-not-retrievable: Could not get user keys
|
||||||
flash.auth.user-sshkeys-not-created: Could not create ssh key
|
flash.auth.user-sshkeys-not-created: Could not create ssh key
|
||||||
flash.auth.must-be-logged-in: You must be logged in to access gists
|
flash.auth.must-be-logged-in: You must be logged in to access gists
|
||||||
|
flash.auth.passkey-registred: Passkey %s registered
|
||||||
|
flash.auth.passkey-deleted: Passkey deleted
|
||||||
|
|
||||||
flash.gist.visibility-changed: Gist visibility has been changed
|
flash.gist.visibility-changed: Gist visibility has been changed
|
||||||
flash.gist.deleted: Gist has been deleted
|
flash.gist.deleted: Gist has been deleted
|
||||||
|
|
|
@ -57,7 +57,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
|
||||||
name := fl.Field().String()
|
name := fl.Field().String()
|
||||||
|
|
||||||
restrictedNames := map[string]struct{}{}
|
restrictedNames := map[string]struct{}{}
|
||||||
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics"} {
|
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn"} {
|
||||||
restrictedNames[restrictedName] = struct{}{}
|
restrictedNames[restrictedName] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/json"
|
gojson "encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
@ -14,6 +15,7 @@ import (
|
||||||
"github.com/markbates/goth/providers/gitlab"
|
"github.com/markbates/goth/providers/gitlab"
|
||||||
"github.com/markbates/goth/providers/openidConnect"
|
"github.com/markbates/goth/providers/openidConnect"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/thomiceli/opengist/internal/auth/webauthn"
|
||||||
"github.com/thomiceli/opengist/internal/config"
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/db"
|
"github.com/thomiceli/opengist/internal/db"
|
||||||
"github.com/thomiceli/opengist/internal/i18n"
|
"github.com/thomiceli/opengist/internal/i18n"
|
||||||
|
@ -166,6 +168,17 @@ func processLogin(ctx echo.Context) error {
|
||||||
return redirect(ctx, "/login")
|
return redirect(ctx, "/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle MFA
|
||||||
|
var hasMFA bool
|
||||||
|
if hasMFA, err = user.HasMFA(); err != nil {
|
||||||
|
return errorRes(500, "Cannot check for user MFA", err)
|
||||||
|
}
|
||||||
|
if hasMFA {
|
||||||
|
sess.Values["mfaID"] = user.ID
|
||||||
|
saveSession(sess, ctx)
|
||||||
|
return redirect(ctx, "/mfa")
|
||||||
|
}
|
||||||
|
|
||||||
sess.Values["user"] = user.ID
|
sess.Values["user"] = user.ID
|
||||||
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
||||||
saveSession(sess, ctx)
|
saveSession(sess, ctx)
|
||||||
|
@ -174,6 +187,10 @@ func processLogin(ctx echo.Context) error {
|
||||||
return redirect(ctx, "/")
|
return redirect(ctx, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mfa(ctx echo.Context) error {
|
||||||
|
return html(ctx, "mfa.html")
|
||||||
|
}
|
||||||
|
|
||||||
func oauthCallback(ctx echo.Context) error {
|
func oauthCallback(ctx echo.Context) error {
|
||||||
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
|
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -376,6 +393,147 @@ func oauthUnlink(ctx echo.Context) error {
|
||||||
return redirect(ctx, "/settings")
|
return redirect(ctx, "/settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func beginWebAuthnBinding(ctx echo.Context) error {
|
||||||
|
credsCreation, jsonWaSession, err := webauthn.BeginBinding(getUserLogged(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return errorRes(500, "Cannot begin WebAuthn registration", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := getSession(ctx)
|
||||||
|
sess.Values["webauthn_registration_session"] = jsonWaSession
|
||||||
|
sess.Options.MaxAge = 5 * 60 // 5 minutes
|
||||||
|
saveSession(sess, ctx)
|
||||||
|
|
||||||
|
return ctx.JSON(200, credsCreation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishWebAuthnBinding(ctx echo.Context) error {
|
||||||
|
sess := getSession(ctx)
|
||||||
|
jsonWaSession, ok := sess.Values["webauthn_registration_session"].([]byte)
|
||||||
|
if !ok {
|
||||||
|
return jsonErrorRes(401, "Cannot get WebAuthn registration session", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := getUserLogged(ctx)
|
||||||
|
|
||||||
|
// extract passkey name from request
|
||||||
|
body, err := io.ReadAll(ctx.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return jsonErrorRes(400, "Failed to read request body", err)
|
||||||
|
}
|
||||||
|
ctx.Request().Body.Close()
|
||||||
|
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
dto := new(db.CrendentialDTO)
|
||||||
|
_ = gojson.Unmarshal(body, &dto)
|
||||||
|
|
||||||
|
if err = ctx.Validate(dto); err != nil {
|
||||||
|
return jsonErrorRes(400, "Invalid request", err)
|
||||||
|
}
|
||||||
|
passkeyName := dto.PasskeyName
|
||||||
|
if passkeyName == "" {
|
||||||
|
passkeyName = "WebAuthn"
|
||||||
|
}
|
||||||
|
|
||||||
|
waCredential, err := webauthn.FinishBinding(user, jsonWaSession, ctx.Request())
|
||||||
|
if err != nil {
|
||||||
|
return jsonErrorRes(403, "Failed binding attempt for passkey", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = db.CreateFromCrendential(user.ID, passkeyName, waCredential); err != nil {
|
||||||
|
return jsonErrorRes(500, "Cannot create WebAuthn credential on database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(sess.Values, "webauthn_registration_session")
|
||||||
|
saveSession(sess, ctx)
|
||||||
|
|
||||||
|
addFlash(ctx, tr(ctx, "flash.auth.passkey-registred", passkeyName), "success")
|
||||||
|
return json(ctx, 200, []string{"OK"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginWebAuthnLogin(ctx echo.Context) error {
|
||||||
|
credsCreation, jsonWaSession, err := webauthn.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := getSession(ctx)
|
||||||
|
sess.Values["webauthn_login_session"] = jsonWaSession
|
||||||
|
sess.Options.MaxAge = 5 * 60 // 5 minutes
|
||||||
|
saveSession(sess, ctx)
|
||||||
|
|
||||||
|
return json(ctx, 200, credsCreation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishWebAuthnLogin(ctx echo.Context) error {
|
||||||
|
sess := getSession(ctx)
|
||||||
|
sessionData, ok := sess.Values["webauthn_login_session"].([]byte)
|
||||||
|
if !ok {
|
||||||
|
return jsonErrorRes(401, "Cannot get WebAuthn login session", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := webauthn.FinishDiscoverableLogin(sessionData, ctx.Request())
|
||||||
|
if err != nil {
|
||||||
|
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Values["user"] = userID
|
||||||
|
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
||||||
|
|
||||||
|
delete(sess.Values, "webauthn_login_session")
|
||||||
|
saveSession(sess, ctx)
|
||||||
|
|
||||||
|
return json(ctx, 200, []string{"OK"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginWebAuthnAssertion(ctx echo.Context) error {
|
||||||
|
sess := getSession(ctx)
|
||||||
|
|
||||||
|
ogUser, err := db.GetUserById(sess.Values["mfaID"].(uint))
|
||||||
|
if err != nil {
|
||||||
|
return jsonErrorRes(500, "Cannot get user", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credsCreation, jsonWaSession, err := webauthn.BeginLogin(ogUser)
|
||||||
|
if err != nil {
|
||||||
|
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Values["webauthn_assertion_session"] = jsonWaSession
|
||||||
|
sess.Options.MaxAge = 5 * 60 // 5 minutes
|
||||||
|
saveSession(sess, ctx)
|
||||||
|
|
||||||
|
return json(ctx, 200, credsCreation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishWebAuthnAssertion(ctx echo.Context) error {
|
||||||
|
sess := getSession(ctx)
|
||||||
|
sessionData, ok := sess.Values["webauthn_assertion_session"].([]byte)
|
||||||
|
if !ok {
|
||||||
|
return jsonErrorRes(401, "Cannot get WebAuthn assertion session", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := sess.Values["mfaID"].(uint)
|
||||||
|
|
||||||
|
ogUser, err := db.GetUserById(userId)
|
||||||
|
if err != nil {
|
||||||
|
return jsonErrorRes(500, "Cannot get user", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = webauthn.FinishLogin(ogUser, sessionData, ctx.Request()); err != nil {
|
||||||
|
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Values["user"] = userId
|
||||||
|
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
||||||
|
|
||||||
|
delete(sess.Values, "webauthn_assertion_session")
|
||||||
|
delete(sess.Values, "mfaID")
|
||||||
|
saveSession(sess, ctx)
|
||||||
|
|
||||||
|
return json(ctx, 200, []string{"OK"})
|
||||||
|
}
|
||||||
|
|
||||||
func logout(ctx echo.Context) error {
|
func logout(ctx echo.Context) error {
|
||||||
deleteSession(ctx)
|
deleteSession(ctx)
|
||||||
deleteCsrfCookie(ctx)
|
deleteCsrfCookie(ctx)
|
||||||
|
@ -427,7 +585,7 @@ func getAvatarUrlFromProvider(provider string, identifier string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
err = json.Unmarshal(body, &result)
|
err = gojson.Unmarshal(body, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
|
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -2,7 +2,7 @@ package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
gojson "encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
htmlpkg "html"
|
htmlpkg "html"
|
||||||
|
@ -215,10 +215,14 @@ func NewServer(isDev bool, sessionsPath string) *Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
e.HTTPErrorHandler = func(er error, ctx echo.Context) {
|
e.HTTPErrorHandler = func(er error, ctx echo.Context) {
|
||||||
if err, ok := er.(*echo.HTTPError); ok {
|
if httpErr, ok := er.(*HTMLError); ok {
|
||||||
setData(ctx, "error", err)
|
setData(ctx, "error", er)
|
||||||
if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil {
|
if fatalErr := htmlWithCode(ctx, httpErr.Code, "error.html"); fatalErr != nil {
|
||||||
log.Fatal().Err(errHtml).Send()
|
log.Fatal().Err(fatalErr).Send()
|
||||||
|
}
|
||||||
|
} else if httpErr, ok := er.(*JSONError); ok {
|
||||||
|
if fatalErr := json(ctx, httpErr.Code, httpErr); fatalErr != nil {
|
||||||
|
log.Fatal().Err(fatalErr).Send()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Fatal().Err(er).Send()
|
log.Fatal().Err(er).Send()
|
||||||
|
@ -238,14 +242,13 @@ func NewServer(isDev bool, sessionsPath string) *Server {
|
||||||
{
|
{
|
||||||
if !dev {
|
if !dev {
|
||||||
g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||||
TokenLookup: "form:_csrf",
|
TokenLookup: "form:_csrf,header:X-CSRF-Token",
|
||||||
CookiePath: "/",
|
CookiePath: "/",
|
||||||
CookieHTTPOnly: true,
|
CookieHTTPOnly: true,
|
||||||
CookieSameSite: http.SameSiteStrictMode,
|
CookieSameSite: http.SameSiteStrictMode,
|
||||||
}))
|
}))
|
||||||
g1.Use(csrfInit)
|
|
||||||
}
|
}
|
||||||
|
g1.Use(csrfInit)
|
||||||
g1.GET("/", create, logged)
|
g1.GET("/", create, logged)
|
||||||
g1.POST("/", processCreate, logged)
|
g1.POST("/", processCreate, logged)
|
||||||
g1.GET("/preview", preview, logged)
|
g1.GET("/preview", preview, logged)
|
||||||
|
@ -261,12 +264,20 @@ func NewServer(isDev bool, sessionsPath string) *Server {
|
||||||
g1.GET("/oauth/:provider", oauth)
|
g1.GET("/oauth/:provider", oauth)
|
||||||
g1.GET("/oauth/:provider/callback", oauthCallback)
|
g1.GET("/oauth/:provider/callback", oauthCallback)
|
||||||
g1.GET("/oauth/:provider/unlink", oauthUnlink, logged)
|
g1.GET("/oauth/:provider/unlink", oauthUnlink, logged)
|
||||||
|
g1.POST("/webauthn/bind", beginWebAuthnBinding, logged)
|
||||||
|
g1.POST("/webauthn/bind/finish", finishWebAuthnBinding, logged)
|
||||||
|
g1.POST("/webauthn/login", beginWebAuthnLogin)
|
||||||
|
g1.POST("/webauthn/login/finish", finishWebAuthnLogin)
|
||||||
|
g1.POST("/webauthn/assertion", beginWebAuthnAssertion, inMFASession)
|
||||||
|
g1.POST("/webauthn/assertion/finish", finishWebAuthnAssertion, inMFASession)
|
||||||
|
g1.GET("/mfa", mfa, inMFASession)
|
||||||
|
|
||||||
g1.GET("/settings", userSettings, logged)
|
g1.GET("/settings", userSettings, logged)
|
||||||
g1.POST("/settings/email", emailProcess, logged)
|
g1.POST("/settings/email", emailProcess, logged)
|
||||||
g1.DELETE("/settings/account", accountDeleteProcess, logged)
|
g1.DELETE("/settings/account", accountDeleteProcess, logged)
|
||||||
g1.POST("/settings/ssh-keys", sshKeysProcess, logged)
|
g1.POST("/settings/ssh-keys", sshKeysProcess, logged)
|
||||||
g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged)
|
g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged)
|
||||||
|
g1.DELETE("/settings/passkeys/:id", passkeyDelete, logged)
|
||||||
g1.PUT("/settings/password", passwordProcess, logged)
|
g1.PUT("/settings/password", passwordProcess, logged)
|
||||||
g1.PUT("/settings/username", usernameProcess, logged)
|
g1.PUT("/settings/username", usernameProcess, logged)
|
||||||
g2 := g1.Group("/admin-panel")
|
g2 := g1.Group("/admin-panel")
|
||||||
|
@ -518,6 +529,17 @@ func logged(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inMFASession(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(ctx echo.Context) error {
|
||||||
|
sess := getSession(ctx)
|
||||||
|
_, ok := sess.Values["mfaID"].(uint)
|
||||||
|
if !ok {
|
||||||
|
return errorRes(400, tr(ctx, "error.not-in-mfa-session"), nil)
|
||||||
|
}
|
||||||
|
return next(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func makeCheckRequireLogin(isSingleGistAccess bool) echo.MiddlewareFunc {
|
func makeCheckRequireLogin(isSingleGistAccess bool) echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(ctx echo.Context) error {
|
return func(ctx echo.Context) error {
|
||||||
|
@ -564,7 +586,7 @@ func parseManifestEntries() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to read manifest.json")
|
log.Fatal().Err(err).Msg("Failed to read manifest.json")
|
||||||
}
|
}
|
||||||
if err = json.Unmarshal(byteValue, &manifestEntries); err != nil {
|
if err = gojson.Unmarshal(byteValue, &manifestEntries); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
|
log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,8 +26,14 @@ func userSettings(ctx echo.Context) error {
|
||||||
return errorRes(500, "Cannot get SSH keys", err)
|
return errorRes(500, "Cannot get SSH keys", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
passkeys, err := db.GetAllCredentialsForUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errorRes(500, "Cannot get WebAuthn credentials", err)
|
||||||
|
}
|
||||||
|
|
||||||
setData(ctx, "email", user.Email)
|
setData(ctx, "email", user.Email)
|
||||||
setData(ctx, "sshKeys", keys)
|
setData(ctx, "sshKeys", keys)
|
||||||
|
setData(ctx, "passkeys", passkeys)
|
||||||
setData(ctx, "hasPassword", user.Password != "")
|
setData(ctx, "hasPassword", user.Password != "")
|
||||||
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
|
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
|
||||||
setData(ctx, "htmlTitle", trH(ctx, "settings"))
|
setData(ctx, "htmlTitle", trH(ctx, "settings"))
|
||||||
|
@ -127,6 +133,26 @@ func sshKeysDelete(ctx echo.Context) error {
|
||||||
return redirect(ctx, "/settings")
|
return redirect(ctx, "/settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func passkeyDelete(ctx echo.Context) error {
|
||||||
|
user := getUserLogged(ctx)
|
||||||
|
keyId, err := strconv.Atoi(ctx.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return redirect(ctx, "/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
passkey, err := db.GetCredentialByIDDB(uint(keyId))
|
||||||
|
if err != nil || passkey.UserID != user.ID {
|
||||||
|
return redirect(ctx, "/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := passkey.Delete(); err != nil {
|
||||||
|
return errorRes(500, "Cannot delete passkey", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addFlash(ctx, tr(ctx, "flash.auth.passkey-deleted"), "success")
|
||||||
|
return redirect(ctx, "/settings")
|
||||||
|
}
|
||||||
|
|
||||||
func passwordProcess(ctx echo.Context) error {
|
func passwordProcess(ctx echo.Context) error {
|
||||||
user := getUserLogged(ctx)
|
user := getUserLogged(ctx)
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,14 @@ import (
|
||||||
|
|
||||||
type dataTypeKey string
|
type dataTypeKey string
|
||||||
|
|
||||||
|
type HTMLError struct {
|
||||||
|
*echo.HTTPError
|
||||||
|
}
|
||||||
|
|
||||||
|
type JSONError struct {
|
||||||
|
*echo.HTTPError
|
||||||
|
}
|
||||||
|
|
||||||
const dataKey dataTypeKey = "data"
|
const dataKey dataTypeKey = "data"
|
||||||
|
|
||||||
func setData(ctx echo.Context, key string, value any) {
|
func setData(ctx echo.Context, key string, value any) {
|
||||||
|
@ -46,6 +54,10 @@ func htmlWithCode(ctx echo.Context, code int, template string) error {
|
||||||
return ctx.Render(code, template, ctx.Request().Context().Value(dataKey))
|
return ctx.Render(code, template, ctx.Request().Context().Value(dataKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func json(ctx echo.Context, code int, data any) error {
|
||||||
|
return ctx.JSON(code, data)
|
||||||
|
}
|
||||||
|
|
||||||
func redirect(ctx echo.Context, location string) error {
|
func redirect(ctx echo.Context, location string) error {
|
||||||
return ctx.Redirect(302, config.C.ExternalUrl+location)
|
return ctx.Redirect(302, config.C.ExternalUrl+location)
|
||||||
}
|
}
|
||||||
|
@ -64,7 +76,16 @@ func errorRes(code int, message string, err error) error {
|
||||||
skipLogger.Error().Err(err).Msg(message)
|
skipLogger.Error().Err(err).Msg(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &echo.HTTPError{Code: code, Message: message, Internal: err}
|
return &HTMLError{&echo.HTTPError{Code: code, Message: message, Internal: err}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonErrorRes(code int, message string, err error) error {
|
||||||
|
if code >= 500 {
|
||||||
|
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
|
||||||
|
skipLogger.Error().Err(err).Msg(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JSONError{&echo.HTTPError{Code: code, Message: message, Internal: err}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserLogged(ctx echo.Context) *db.User {
|
func getUserLogged(ctx echo.Context) *db.User {
|
||||||
|
@ -102,14 +123,15 @@ func saveSession(sess *sessions.Session, ctx echo.Context) {
|
||||||
func deleteSession(ctx echo.Context) {
|
func deleteSession(ctx echo.Context) {
|
||||||
sess := getSession(ctx)
|
sess := getSession(ctx)
|
||||||
sess.Options.MaxAge = -1
|
sess.Options.MaxAge = -1
|
||||||
sess.Values["user"] = nil
|
|
||||||
saveSession(sess, ctx)
|
saveSession(sess, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCsrfHtmlForm(ctx echo.Context) {
|
func setCsrfHtmlForm(ctx echo.Context) {
|
||||||
|
var csrf string
|
||||||
if csrfToken, ok := ctx.Get("csrf").(string); ok {
|
if csrfToken, ok := ctx.Get("csrf").(string); ok {
|
||||||
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrfToken+`">`))
|
csrf = csrfToken
|
||||||
}
|
}
|
||||||
|
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteCsrfCookie(ctx echo.Context) {
|
func deleteCsrfCookie(ctx echo.Context) {
|
||||||
|
|
3
public/vite.config.js
vendored
3
public/vite.config.js
vendored
|
@ -14,7 +14,8 @@ export default defineConfig({
|
||||||
'./public/editor.ts',
|
'./public/editor.ts',
|
||||||
'./public/admin.ts',
|
'./public/admin.ts',
|
||||||
'./public/gist.ts',
|
'./public/gist.ts',
|
||||||
'./public/embed.ts'
|
'./public/embed.ts',
|
||||||
|
'./public/webauthn.ts'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
assetsInlineLimit: 0,
|
assetsInlineLimit: 0,
|
||||||
|
|
170
public/webauthn.ts
Normal file
170
public/webauthn.ts
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
let loginMethod = "login"
|
||||||
|
|
||||||
|
function encodeArrayBufferToBase64Url(buffer) {
|
||||||
|
const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
|
||||||
|
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64UrlToArrayBuffer(base64Url) {
|
||||||
|
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (base64.length % 4) {
|
||||||
|
base64 += '=';
|
||||||
|
}
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const buffer = new ArrayBuffer(binaryString.length);
|
||||||
|
const view = new Uint8Array(buffer);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
view[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindPasskey() {
|
||||||
|
let waitText = document.getElementById("login-passkey-wait");
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.classList.add('hidden');
|
||||||
|
waitText.classList.remove('hidden');
|
||||||
|
|
||||||
|
let csrf = document.querySelector<HTMLInputElement>('form#webauthn input[name="_csrf"]').value
|
||||||
|
|
||||||
|
const beginResponse = await fetch('/webauthn/bind', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: new FormData(document.querySelector<HTMLFormElement>('form#webauthn'))
|
||||||
|
});
|
||||||
|
const beginData = await beginResponse.json();
|
||||||
|
|
||||||
|
beginData.publicKey.challenge = decodeBase64UrlToArrayBuffer(beginData.publicKey.challenge);
|
||||||
|
beginData.publicKey.user.id = decodeBase64UrlToArrayBuffer(beginData.publicKey.user.id);
|
||||||
|
for (const cred of beginData.publicKey.excludeCredentials ?? []) {
|
||||||
|
cred.id = decodeBase64UrlToArrayBuffer(cred.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: beginData.publicKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential || !credential.rawId || !credential.response) {
|
||||||
|
throw new Error('Credential object is missing required properties');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishResponse = await fetch('/webauthn/bind/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrf
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: credential.id,
|
||||||
|
rawId: encodeArrayBufferToBase64Url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
attestationObject: encodeArrayBufferToBase64Url(credential.response.attestationObject),
|
||||||
|
clientDataJSON: encodeArrayBufferToBase64Url(credential.response.clientDataJSON),
|
||||||
|
},
|
||||||
|
type: credential.type,
|
||||||
|
passkeyname: document.querySelector<HTMLInputElement>('form#webauthn input[name="passkeyname"]').value
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const finishData = await finishResponse.json();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during passkey registration:', error);
|
||||||
|
waitText.classList.add('hidden');
|
||||||
|
this.classList.remove('hidden');
|
||||||
|
alert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithPasskey() {
|
||||||
|
let waitText = document.getElementById("login-passkey-wait");
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.classList.add('hidden');
|
||||||
|
waitText.classList.remove('hidden');
|
||||||
|
|
||||||
|
let csrf = document.querySelector<HTMLInputElement>('form#webauthn input[name="_csrf"]').value
|
||||||
|
const beginResponse = await fetch('/webauthn/' + loginMethod, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: new FormData(document.querySelector<HTMLFormElement>('form#webauthn'))
|
||||||
|
});
|
||||||
|
const beginData = await beginResponse.json();
|
||||||
|
|
||||||
|
beginData.publicKey.challenge = decodeBase64UrlToArrayBuffer(beginData.publicKey.challenge);
|
||||||
|
|
||||||
|
if (beginData.publicKey.allowCredentials) {
|
||||||
|
beginData.publicKey.allowCredentials = beginData.publicKey.allowCredentials.map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: decodeBase64UrlToArrayBuffer(cred.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: beginData.publicKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential || !credential.rawId || !credential.response) {
|
||||||
|
throw new Error('Credential object is missing required properties');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishResponse = await fetch('/webauthn/' + loginMethod + '/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrf
|
||||||
|
},
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: credential.id,
|
||||||
|
rawId: encodeArrayBufferToBase64Url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
authenticatorData: encodeArrayBufferToBase64Url(credential.response.authenticatorData),
|
||||||
|
clientDataJSON: encodeArrayBufferToBase64Url(credential.response.clientDataJSON),
|
||||||
|
signature: encodeArrayBufferToBase64Url(credential.response.signature),
|
||||||
|
userHandle: encodeArrayBufferToBase64Url(credential.response.userHandle),
|
||||||
|
},
|
||||||
|
type: credential.type,
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const finishData = await finishResponse.json();
|
||||||
|
|
||||||
|
if (!finishResponse.ok) {
|
||||||
|
throw new Error(finishData.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
waitText.classList.add('hidden');
|
||||||
|
this.classList.remove('hidden');
|
||||||
|
alert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const registerButton = document.getElementById('bind-passkey-button');
|
||||||
|
if (registerButton) {
|
||||||
|
registerButton.addEventListener('click', bindPasskey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.documentURI.includes('/mfa')) {
|
||||||
|
loginMethod = "assertion"
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginButton = document.getElementById('login-passkey-button');
|
||||||
|
if (loginButton) {
|
||||||
|
loginButton.addEventListener('click', loginWithPasskey);
|
||||||
|
}
|
||||||
|
});
|
159
templates/pages/auth_form.html
vendored
159
templates/pages/auth_form.html
vendored
|
@ -11,83 +11,110 @@
|
||||||
{{ if .disableSignup }}
|
{{ if .disableSignup }}
|
||||||
<p class="italic">{{ .locale.Tr "auth.signup-disabled" }}</p>
|
<p class="italic">{{ .locale.Tr "auth.signup-disabled" }}</p>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="sm:col-span-6">
|
<div class="grid sm:grid-cols-2">
|
||||||
<div class="mt-8 sm:w-full sm:max-w-md">
|
<div class="">
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
<div class="mt-8 sm:w-full sm:max-w-md">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||||
|
|
||||||
{{ if not .disableForm }}
|
|
||||||
<form class="space-y-6" method="post">
|
|
||||||
<div>
|
|
||||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.username" }} </label>
|
|
||||||
<div class="mt-1">
|
|
||||||
<input id="username" name="username" type="text" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.password" }} </label>
|
|
||||||
<div class="mt-1">
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ if .isLoginPage }}
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-auto">
|
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.login" }}</button>
|
|
||||||
</div>
|
|
||||||
{{ if not .DisableSignup }}
|
|
||||||
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/register">{{ .locale.Tr "auth.register-instead" }} →</a></span>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-auto">
|
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.signup" }}</button>
|
|
||||||
</div>
|
|
||||||
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/login">{{ .locale.Tr "auth.login-instead" }} →</a></span>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ .csrfHtml }}
|
|
||||||
</form>
|
|
||||||
{{ end }}
|
|
||||||
{{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }}
|
|
||||||
{{ if not .disableForm }}
|
{{ if not .disableForm }}
|
||||||
<div class="relative my-4">
|
<form class="space-y-6" method="post">
|
||||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
<div>
|
||||||
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
|
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.username" }} </label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input id="username" name="username" type="text" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.password" }} </label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if .isLoginPage }}
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-auto">
|
||||||
|
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.login" }}</button>
|
||||||
|
</div>
|
||||||
|
{{ if not .DisableSignup }}
|
||||||
|
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/register">{{ .locale.Tr "auth.register-instead" }} →</a></span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-auto">
|
||||||
|
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.signup" }}</button>
|
||||||
|
</div>
|
||||||
|
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/login">{{ .locale.Tr "auth.login-instead" }} →</a></span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ .csrfHtml }}
|
||||||
|
</form>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div>
|
{{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }}
|
||||||
{{ if .githubOauth }}
|
{{ if not .disableForm }}
|
||||||
<a href="{{ $.c.ExternalUrl }}/oauth/github" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
<div class="relative my-4">
|
||||||
{{ .locale.Tr "auth.oauth" "GitHub"}}
|
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
</a>
|
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if .gitlabOauth }}
|
<div>
|
||||||
<a href="{{ $.c.ExternalUrl }}/oauth/gitlab" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
{{ if .githubOauth }}
|
||||||
{{ .locale.Tr "auth.oauth" .c.GitlabName}}
|
<a href="{{ $.c.ExternalUrl }}/oauth/github" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
||||||
</a>
|
{{ .locale.Tr "auth.oauth" "GitHub"}}
|
||||||
{{ end }}
|
</a>
|
||||||
{{ if .giteaOauth }}
|
{{ end }}
|
||||||
<a href="{{ $.c.ExternalUrl }}/oauth/gitea" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
{{ if .gitlabOauth }}
|
||||||
{{ .locale.Tr "auth.oauth" .c.GiteaName }}
|
<a href="{{ $.c.ExternalUrl }}/oauth/gitlab" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
||||||
</a>
|
{{ .locale.Tr "auth.oauth" .c.GitlabName}}
|
||||||
{{ end }}
|
</a>
|
||||||
{{ if .oidcOauth }}
|
{{ end }}
|
||||||
<a href="{{ $.c.ExternalUrl }}/oauth/openid-connect" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
{{ if .giteaOauth }}
|
||||||
Continue with OpenID account
|
<a href="{{ $.c.ExternalUrl }}/oauth/gitea" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
||||||
</a>
|
{{ .locale.Tr "auth.oauth" .c.GiteaName }}
|
||||||
{{ end }}
|
</a>
|
||||||
</div>
|
{{ end }}
|
||||||
{{ end }}
|
{{ if .oidcOauth }}
|
||||||
|
<a href="{{ $.c.ExternalUrl }}/oauth/openid-connect" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
||||||
|
Continue with OpenID account
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ if .isLoginPage }}
|
||||||
|
<div class="">
|
||||||
|
<div class="mt-8 sm:w-full sm:max-w-md">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 ">
|
||||||
|
<p class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.passkey" }}</p>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-14">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center mt-4">
|
||||||
|
<form id="webauthn">
|
||||||
|
{{ .csrfHtml }}
|
||||||
|
<button type="button" id="login-passkey-button" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.mfa.login-with-passkey" }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<p id="login-passkey-wait" class="hidden text-sm font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
|
||||||
|
|
||||||
{{ template "footer" .}}
|
{{ template "footer" .}}
|
||||||
|
|
37
templates/pages/mfa.html
vendored
Normal file
37
templates/pages/mfa.html
vendored
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{{ template "header" .}}
|
||||||
|
|
||||||
|
<div class="py-10">
|
||||||
|
<header class="pb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "auth.mfa" }}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="mt-8 sm:w-full sm:max-w-md mx-auto">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 ">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<p class="block text-md font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.use-passkey-to-finish" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center mt-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-14">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center mt-4">
|
||||||
|
<form id="webauthn">
|
||||||
|
{{ .csrfHtml }}
|
||||||
|
<button type="button" id="login-passkey-button" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.mfa.use-passkey" }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<p id="login-passkey-wait" class="hidden text-sm font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
|
||||||
|
|
||||||
|
|
||||||
|
{{ template "footer" .}}
|
62
templates/pages/settings.html
vendored
62
templates/pages/settings.html
vendored
|
@ -148,6 +148,64 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||||
|
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
|
||||||
|
{{ .locale.Tr "auth.mfa.passkeys" }}
|
||||||
|
</h2>
|
||||||
|
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||||
|
{{ .locale.Tr "auth.mfa.passkeys-help" }}
|
||||||
|
</h3>
|
||||||
|
<form class="space-y-6" id="webauthn">
|
||||||
|
<div>
|
||||||
|
<label for="passkeyname" class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.passkey-name" }}</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input id="passkeyname" name="passkeyname" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ .csrfHtml }}
|
||||||
|
<button id="bind-passkey-button" type="button" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.mfa.bind-passkey" }}</button>
|
||||||
|
</form>
|
||||||
|
<div class="flex items-center justify-center mt-4">
|
||||||
|
<p id="login-passkey-wait" class="hidden text-sm font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mt-6 flow-root">
|
||||||
|
<ul role="list" class="-my-5 divide-y divide-gray-300 dark:divide-gray-700 list-none">
|
||||||
|
{{ if .passkeys }}
|
||||||
|
{{ range $passkey := .passkeys }}
|
||||||
|
<li class="py-5">
|
||||||
|
<div class="inline-flex">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mr-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .Name }}</h3>
|
||||||
|
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "auth.mfa.passkey-added-at" }} <span class="moment-timestamp-date">{{ .CreatedAt }}</span></p>
|
||||||
|
{{ if eq .LastUsedAt 0 }}
|
||||||
|
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "auth.mfa.passkey-never-used" }}</p>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "auth.mfa.passkey-last-used" }} <span class="moment-timestamp">{{ .LastUsedAt }}</span></p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<form action="{{ $.c.ExternalUrl }}/settings/passkeys/{{.ID}}" method="post" class="inline-block" onsubmit="return confirm('{{ $.locale.Tr "auth.mfa.delete-passkey-confirm" }}');">
|
||||||
|
<input type="hidden" name="_method" value="DELETE">
|
||||||
|
{{ $.csrfHtml }}
|
||||||
|
<button type="submit" class="align-middle items-center leading-2 ml-2 px-3 py-1 border border-transparent border-gray-200 dark:border-gray-700 text-xs font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">{{ $.locale.Tr "auth.mfa.delete-passkey" }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
|
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||||
|
@ -225,4 +283,8 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
|
||||||
|
|
||||||
|
|
||||||
{{ template "footer" .}}
|
{{ template "footer" .}}
|
||||||
|
|
Loading…
Reference in a new issue