Add passkeys support + MFA (#341)

This commit is contained in:
Thomas Miceli 2024-10-07 23:56:32 +02:00 committed by GitHub
parent 41dc2e451b
commit 6959929094
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1073 additions and 105 deletions

15
go.mod
View file

@ -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

42
go.sum
View file

@ -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=

View 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
}

View 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
}

View file

@ -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))

View file

@ -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{})
}

40
internal/db/types.go Normal file
View 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"
}
}

View file

@ -21,6 +21,7 @@ type User struct {
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 {

View 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"`
}

View file

@ -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

View file

@ -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{}{}
}

View file

@ -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 ""

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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(`<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) {

View file

@ -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,

170
public/webauthn.ts Normal file
View 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);
}
});

View file

@ -11,7 +11,8 @@
{{ if .disableSignup }}
<p class="italic">{{ .locale.Tr "auth.signup-disabled" }}</p>
{{ else }}
<div class="sm:col-span-6">
<div class="grid sm:grid-cols-2">
<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">
@ -86,8 +87,34 @@
</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>
{{ end }}
</main>
</div>
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
{{ template "footer" .}}

37
templates/pages/mfa.html vendored Normal file
View 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" .}}

View file

@ -148,6 +148,64 @@
</div>
</div>
{{ 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="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">
@ -225,4 +283,8 @@
</div>
</main>
</div>
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
{{ template "footer" .}}