diff --git a/go.mod b/go.mod index ea3a525..40ce77d 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/glebarez/sqlite v1.11.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/gorilla/securecookie v1.1.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-highlighting/v2 v2.0.0-20230729083705-37449abec8cc go.abhg.dev/goldmark/mermaid v0.5.0 - golang.org/x/crypto v0.23.0 - golang.org/x/text v0.15.0 + golang.org/x/crypto v0.26.0 + golang.org/x/text v0.17.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.9 @@ -53,15 +54,19 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // 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/glebarez/go-sqlite v1.22.0 // indirect github.com/go-playground/locales v0.14.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-webauthn/x v0.1.12 // 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/protobuf v1.5.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/hashicorp/go-immutable-radix v1.3.1 // 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-isatty v0.0.20 // 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/reflect2 v1.0.2 // 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/valyala/bytebufferpool v1.0.0 // 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 go.etcd.io/bbolt v1.3.10 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.23.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.34.1 // indirect modernc.org/libc v1.51.0 // indirect diff --git a/go.sum b/go.sum index 39fbb83..b8c707a 100644 --- a/go.sum +++ b/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/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/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/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= 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-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-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/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 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/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/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/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U= 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/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.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.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 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/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 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/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 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.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= 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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +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/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= 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.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +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-20220811171246-fbc7d0a398ab/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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +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/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +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/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/auth/webauthn/user.go b/internal/auth/webauthn/user.go new file mode 100644 index 0000000..bf45b22 --- /dev/null +++ b/internal/auth/webauthn/user.go @@ -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 +} diff --git a/internal/auth/webauthn/webauthn.go b/internal/auth/webauthn/webauthn.go new file mode 100644 index 0000000..523914a --- /dev/null +++ b/internal/auth/webauthn/webauthn.go @@ -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 +} diff --git a/internal/cli/main.go b/internal/cli/main.go index 9b9ee3f..43aa5cc 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "github.com/rs/zerolog/log" + "github.com/thomiceli/opengist/internal/auth/webauthn" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "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") } + if err := webauthn.Init(config.C.ExternalUrl); err != nil { + log.Error().Err(err).Msg("Failed to initialize WebAuthn") + } + if config.C.IndexEnabled { log.Info().Msg("Index directory: " + filepath.Join(homePath, config.C.IndexDirname)) index.Init(filepath.Join(homePath, config.C.IndexDirname)) diff --git a/internal/db/db.go b/internal/db/db.go index 49f93b3..f8b4b30 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -137,7 +137,7 @@ func Setup(dbUri string, sharedCache bool) error { 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 } @@ -241,5 +241,5 @@ func DeprecationDBFilename() { } func TruncateDatabase() error { - return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}) + return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}) } diff --git a/internal/db/types.go b/internal/db/types.go new file mode 100644 index 0000000..f3e8d2d --- /dev/null +++ b/internal/db/types.go @@ -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" + } +} diff --git a/internal/db/user.go b/internal/db/user.go index 492bcbd..d3e2045 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -18,9 +18,10 @@ type User struct { GiteaID string OIDCID string `gorm:"column:oidc_id"` - Gists []Gist `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"` + Gists []Gist `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"` + WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` } func (user *User) BeforeDelete(tx *gorm.DB) error { @@ -58,6 +59,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error { return err } + err = tx.Where("user_id = ?", user.ID).Delete(&WebAuthnCredential{}).Error + if err != nil { + return err + } + // Delete all gists created by this user return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error } @@ -200,6 +206,13 @@ func (user *User) DeleteProviderID(provider string) error { 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 -- // type UserDTO struct { diff --git a/internal/db/webauth_credential.go b/internal/db/webauth_credential.go new file mode 100644 index 0000000..742c0b2 --- /dev/null +++ b/internal/db/webauth_credential.go @@ -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"` +} diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index 55d7cf0..75a2312 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -143,6 +143,22 @@ auth.password: Password auth.register-instead: Register instead auth.login-instead: Login instead 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.page-not-found: Page not found @@ -155,6 +171,7 @@ error.oauth-unsupported: Unsupported provider error.cannot-bind-data: Cannot bind data error.invalid-number: Invalid number 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.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-created: Could not create ssh key 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.deleted: Gist has been deleted diff --git a/internal/utils/validator.go b/internal/utils/validator.go index af78da4..8d13569 100644 --- a/internal/utils/validator.go +++ b/internal/utils/validator.go @@ -57,7 +57,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool { name := fl.Field().String() 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{}{} } diff --git a/internal/web/auth.go b/internal/web/auth.go index de3f477..6db4c6f 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -1,9 +1,10 @@ package web import ( + "bytes" "context" "crypto/md5" - "encoding/json" + gojson "encoding/json" "errors" "fmt" "github.com/labstack/echo/v4" @@ -14,6 +15,7 @@ import ( "github.com/markbates/goth/providers/gitlab" "github.com/markbates/goth/providers/openidConnect" "github.com/rs/zerolog/log" + "github.com/thomiceli/opengist/internal/auth/webauthn" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/i18n" @@ -166,6 +168,17 @@ func processLogin(ctx echo.Context) error { 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.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year saveSession(sess, ctx) @@ -174,6 +187,10 @@ func processLogin(ctx echo.Context) error { return redirect(ctx, "/") } +func mfa(ctx echo.Context) error { + return html(ctx, "mfa.html") +} + func oauthCallback(ctx echo.Context) error { user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request()) if err != nil { @@ -376,6 +393,147 @@ func oauthUnlink(ctx echo.Context) error { 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 { deleteSession(ctx) deleteCsrfCookie(ctx) @@ -427,7 +585,7 @@ func getAvatarUrlFromProvider(provider string, identifier string) string { } var result map[string]interface{} - err = json.Unmarshal(body, &result) + err = gojson.Unmarshal(body, &result) if err != nil { log.Error().Err(err).Msg("Cannot unmarshal Gitea response body") return "" diff --git a/internal/web/server.go b/internal/web/server.go index a98929a..4869b6e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -2,7 +2,7 @@ package web import ( "context" - "encoding/json" + gojson "encoding/json" "errors" "fmt" htmlpkg "html" @@ -215,10 +215,14 @@ func NewServer(isDev bool, sessionsPath string) *Server { } e.HTTPErrorHandler = func(er error, ctx echo.Context) { - if err, ok := er.(*echo.HTTPError); ok { - setData(ctx, "error", err) - if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil { - log.Fatal().Err(errHtml).Send() + if httpErr, ok := er.(*HTMLError); ok { + setData(ctx, "error", er) + if fatalErr := htmlWithCode(ctx, httpErr.Code, "error.html"); fatalErr != nil { + 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 { log.Fatal().Err(er).Send() @@ -238,14 +242,13 @@ func NewServer(isDev bool, sessionsPath string) *Server { { if !dev { g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ - TokenLookup: "form:_csrf", + TokenLookup: "form:_csrf,header:X-CSRF-Token", CookiePath: "/", CookieHTTPOnly: true, CookieSameSite: http.SameSiteStrictMode, })) - g1.Use(csrfInit) } - + g1.Use(csrfInit) g1.GET("/", create, logged) g1.POST("/", processCreate, 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/callback", oauthCallback) 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.POST("/settings/email", emailProcess, logged) g1.DELETE("/settings/account", accountDeleteProcess, logged) g1.POST("/settings/ssh-keys", sshKeysProcess, 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/username", usernameProcess, logged) 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 { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { @@ -564,7 +586,7 @@ func parseManifestEntries() { if err != nil { 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") } } diff --git a/internal/web/settings.go b/internal/web/settings.go index 02a0261..57cf407 100644 --- a/internal/web/settings.go +++ b/internal/web/settings.go @@ -26,8 +26,14 @@ func userSettings(ctx echo.Context) error { 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, "sshKeys", keys) + setData(ctx, "passkeys", passkeys) setData(ctx, "hasPassword", user.Password != "") setData(ctx, "disableForm", getData(ctx, "DisableLoginForm")) setData(ctx, "htmlTitle", trH(ctx, "settings")) @@ -127,6 +133,26 @@ func sshKeysDelete(ctx echo.Context) error { 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 { user := getUserLogged(ctx) diff --git a/internal/web/util.go b/internal/web/util.go index 7e355e5..cc165b8 100644 --- a/internal/web/util.go +++ b/internal/web/util.go @@ -19,6 +19,14 @@ import ( type dataTypeKey string +type HTMLError struct { + *echo.HTTPError +} + +type JSONError struct { + *echo.HTTPError +} + const dataKey dataTypeKey = "data" 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)) } +func json(ctx echo.Context, code int, data any) error { + return ctx.JSON(code, data) +} + func redirect(ctx echo.Context, location string) error { 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) } - 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 { @@ -102,14 +123,15 @@ func saveSession(sess *sessions.Session, ctx echo.Context) { func deleteSession(ctx echo.Context) { sess := getSession(ctx) sess.Options.MaxAge = -1 - sess.Values["user"] = nil saveSession(sess, ctx) } func setCsrfHtmlForm(ctx echo.Context) { + var csrf string if csrfToken, ok := ctx.Get("csrf").(string); ok { - setData(ctx, "csrfHtml", template.HTML(``)) + csrf = csrfToken } + setData(ctx, "csrfHtml", template.HTML(``)) } func deleteCsrfCookie(ctx echo.Context) { diff --git a/public/vite.config.js b/public/vite.config.js index 361818e..3fb9176 100644 --- a/public/vite.config.js +++ b/public/vite.config.js @@ -14,7 +14,8 @@ export default defineConfig({ './public/editor.ts', './public/admin.ts', './public/gist.ts', - './public/embed.ts' + './public/embed.ts', + './public/webauthn.ts' ] }, assetsInlineLimit: 0, diff --git a/public/webauthn.ts b/public/webauthn.ts new file mode 100644 index 0000000..1517735 --- /dev/null +++ b/public/webauthn.ts @@ -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('form#webauthn input[name="_csrf"]').value + + const beginResponse = await fetch('/webauthn/bind', { + method: 'POST', + credentials: 'include', + body: new FormData(document.querySelector('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('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('form#webauthn input[name="_csrf"]').value + const beginResponse = await fetch('/webauthn/' + loginMethod, { + method: 'POST', + credentials: 'include', + body: new FormData(document.querySelector('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); + } +}); diff --git a/templates/pages/auth_form.html b/templates/pages/auth_form.html index cbcc57f..682a878 100644 --- a/templates/pages/auth_form.html +++ b/templates/pages/auth_form.html @@ -11,83 +11,110 @@ {{ if .disableSignup }}

{{ .locale.Tr "auth.signup-disabled" }}

{{ else }} -
-
-
+
+
+
+
- {{ if not .disableForm }} -
-
- -
- -
-
- -
- -
- -
-
- {{ if .isLoginPage }} -
-
- -
- {{ if not .DisableSignup }} - {{ .locale.Tr "auth.register-instead" }} → - {{ end }} -
- {{ else }} -
-
- -
- {{ .locale.Tr "auth.login-instead" }} → - -
- {{ end }} - {{ .csrfHtml }} -
- {{ end }} - {{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }} {{ if not .disableForm }} -
-
+ {{ if .isLoginPage }} +
+
+
+

{{ .locale.Tr "auth.mfa.passkey" }}

+
+ + + +
+
+
+ {{ .csrfHtml }} + +
+
+
+ +
+
+
+
+ {{ end }}
{{ end }}
+ + {{ template "footer" .}} diff --git a/templates/pages/mfa.html b/templates/pages/mfa.html new file mode 100644 index 0000000..27c7e5e --- /dev/null +++ b/templates/pages/mfa.html @@ -0,0 +1,37 @@ +{{ template "header" .}} + +
+
+
+

{{ .locale.Tr "auth.mfa" }}

+
+
+
+
+
+
+

{{ .locale.Tr "auth.mfa.use-passkey-to-finish" }}

+
+
+ + + +
+
+
+ {{ .csrfHtml }} + +
+
+
+ +
+
+
+
+
+ + + + +{{ template "footer" .}} diff --git a/templates/pages/settings.html b/templates/pages/settings.html index 02c43e9..34b658e 100644 --- a/templates/pages/settings.html +++ b/templates/pages/settings.html @@ -148,6 +148,64 @@
{{ end }} + +
+
+
+

+ {{ .locale.Tr "auth.mfa.passkeys" }} +

+

+ {{ .locale.Tr "auth.mfa.passkeys-help" }} +

+
+
+ +
+ +
+
+ {{ .csrfHtml }} + +
+
+ +
+
+
+
+
+
    + {{ if .passkeys }} + {{ range $passkey := .passkeys }} +
  • +
    + + + +
    +

    {{ .Name }}

    +

    {{ $.locale.Tr "auth.mfa.passkey-added-at" }} {{ .CreatedAt }}

    + {{ if eq .LastUsedAt 0 }} +

    {{ $.locale.Tr "auth.mfa.passkey-never-used" }}

    + {{ else }} +

    {{ $.locale.Tr "auth.mfa.passkey-last-used" }} {{ .LastUsedAt }}

    + {{ end }} +
    +
    + + {{ $.csrfHtml }} + +
    +
    +
  • + {{ end }} + {{ end }} +
+
+
+
+
@@ -225,4 +283,8 @@
+ + + + {{ template "footer" .}}