From af85c78af641c3ebfdafbac2bc1ba7d22b462a68 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Fri, 5 Sep 2025 16:05:15 -0400 Subject: [PATCH 1/9] Add MFA method for passkey auth --- app/application.go | 5 ++++ common/context.go | 5 ++++ go.mod | 15 +++++------ go.sum | 13 ++++++++++ network/webauthn.go | 22 ++++++++++++++++ views/users/passkey_login.html | 40 ++++++++++++++++++++++++++++++ views/users/passkey_register.html | 40 ++++++++++++++++++++++++++++++ web/webui/two_factor_controller.go | 32 ++++++++++++++++++++++++ 8 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 network/webauthn.go create mode 100644 views/users/passkey_login.html create mode 100644 views/users/passkey_register.html diff --git a/app/application.go b/app/application.go index 1c670477..1d3b86f8 100644 --- a/app/application.go +++ b/app/application.go @@ -283,6 +283,11 @@ func initRoutes(router *gin.Engine) { // UI Components webRoutes.GET("/ui_components", webui.ComponentsIndex) + // User MFA - Passkeys + webRoutes.GET("/users/begin_passkey_registration", webui.UserBeginPasskeyRegistration) + webRoutes.GET("/users/finish_passkey_registration", webui.UserFinishPasskeyRegistration) + webRoutes.GET("/users/begin_login_with_passkey", webui.UserBeginLoginWithPasskey) + webRoutes.GET("/users/finish_login_with_passkey", webui.UserFinishLoginWithPasskey) } // Root goes to sign-in page, which is a web route, diff --git a/common/context.go b/common/context.go index cee1c0c7..6d6ef3dd 100644 --- a/common/context.go +++ b/common/context.go @@ -8,6 +8,7 @@ import ( "github.com/APTrust/registry/constants" "github.com/APTrust/registry/network" "github.com/go-pg/pg/v10" + "github.com/go-webuthn/webauthn" "github.com/rs/zerolog" ) @@ -46,6 +47,9 @@ type APTContext struct { // SMTPClient is for sending emails from a private subnet that // is not using a NAT gateway. SMTPClient *network.SMTPClient + + // WebAuthnClient is used for passkey authentication + WebAuthn *webauthn.WebAuthn } // Context returns an APTContext object, which includes @@ -90,6 +94,7 @@ func Context() *APTContext { SESClient: network.NewSESClient(config.Email.Enabled, config.TwoFactor.AWSRegion, config.Email.SesEndpoint, config.Email.SesUser, config.Email.SesPassword, config.Email.FromAddress, zlogger), SNSClient: network.NewSNSClient(config.TwoFactor.SMSEnabled, config.TwoFactor.AWSRegion, config.TwoFactor.SNSEndpoint, config.TwoFactor.SNSUser, config.TwoFactor.SNSPassword, zlogger), SMTPClient: network.NewSMTPClient(config.Email.Enabled, config.TwoFactor.AWSRegion, config.Email.SesEndpoint, config.Email.SesUser, config.Email.SesPassword, config.Email.FromAddress, zlogger), + WebAuthn: network.NewWebAuthn(), RedisClient: redisClient, } } diff --git a/go.mod b/go.mod index eb0fcd7f..7affd552 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-pg/pg/v10 v10.12.0 github.com/go-redis/redis/v7 v7.4.1 - github.com/google/uuid v1.2.0 + github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.1 github.com/jinzhu/copier v0.3.0 github.com/nsqio/nsq v1.2.0 @@ -22,9 +22,9 @@ require ( github.com/rs/zerolog v1.20.0 github.com/spf13/viper v1.7.1 github.com/stretchr/stew v0.0.0-20130812190256-80ef0842b48b - github.com/stretchr/testify v1.8.3 - golang.org/x/crypto v0.36.0 - golang.org/x/text v0.23.0 + github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.40.0 + golang.org/x/text v0.27.0 ) require ( @@ -43,6 +43,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-webauthn/webauthn v0.13.4 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gojektech/heimdall v5.0.2+incompatible // indirect @@ -63,7 +64,7 @@ require ( github.com/magiconair/properties v1.8.1 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/mitchellh/mapstructure v1.1.2 // 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/nsqio/go-diskqueue v0.0.0-20180306152900-74cfbc9de839 // indirect @@ -95,8 +96,8 @@ require ( github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/net v0.37.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.34.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/ini.v1 v1.51.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 76495f6b..7566269b 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI= github.com/go-redis/redis/v7 v7.4.1/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ= +github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= @@ -141,6 +143,8 @@ github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OI github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= @@ -232,6 +236,8 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +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= @@ -333,6 +339,7 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= @@ -398,6 +405,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -444,6 +453,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -486,6 +497,7 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -498,6 +510,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/network/webauthn.go b/network/webauthn.go new file mode 100644 index 00000000..5e5daeb1 --- /dev/null +++ b/network/webauthn.go @@ -0,0 +1,22 @@ +package network + +import ( + "errors" + stdlog "log" + "net/url" + "os" + "time" +) + +func NewWebAuthn() WebAuthn { + wconfig := &webAuthn.Config{ + RPDisplayName: "APTrust" + RPID: "localhost", + RPOrigins: []string{"http://localhost:8080"} + } + webauthn, err := webauthn.New(wconfig) + if err != nil { + return nil + } + return webauthn +} diff --git a/views/users/passkey_login.html b/views/users/passkey_login.html new file mode 100644 index 00000000..c7d33b63 --- /dev/null +++ b/views/users/passkey_login.html @@ -0,0 +1,40 @@ +{{ define "users/passkey_login.html" }} + + +{{ if not .showAsModal }} + {{ template "shared/_header.html" .}} +{{ end }} + + + + + + + + +{{ if not .showAsModal }} + {{ template "shared/_footer.html" .}} +{{ end }} + + +{{ end }} diff --git a/views/users/passkey_register.html b/views/users/passkey_register.html new file mode 100644 index 00000000..5a2b3b6b --- /dev/null +++ b/views/users/passkey_register.html @@ -0,0 +1,40 @@ +{{ define "users/passkey_register.html" }} + + +{{ if not .showAsModal }} + {{ template "shared/_header.html" .}} +{{ end }} + + + + + + + + +{{ if not .showAsModal }} + {{ template "shared/_footer.html" .}} +{{ end }} + + +{{ end }} diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index a20e112c..1c7dfb48 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -397,3 +397,35 @@ func OTPTokenIsExpired(tokenSentAt time.Time) bool { expiration := tokenSentAt.Add(common.Context().Config.TwoFactor.OTPExpiration) return time.Now().After(expiration) } + +func UserBeginPasskeyRegistration(c *gin.Context) { + // user := NewRequest(c).CurrentUser + // options, session, err := common.Context().WebAuthn.BeginRegistration(webauthn.User{}) + // if err != nil + // Save session to DB table + c.Redirect(http.StatusFound, "/dashboard") +} + +func UserFinishPasskeyRegistration(c *gin.Context) { + // Get Session-Key from header + // Get Session from DB + // credential, err := webauthn.FinishRegistration(webauth.User{}, webauthn.SessionData{}, *http.Request) + // if err != nil + // user.AddCredential(credential) + // Save user + // Delete session + c.Redirect(http.StatusFound, "/dashboard") +} + +func UserBeginLoginWithPasskey(c *gin.Context) { + // options, session, err := webauthn.BeginLogin(user) + c.Redirect(http.StatusFound, "/dashboard") +} + +func UserFinishLoginWithPasskey(c *gin.Context) { + // credential, err := webauthn.FinishLogin(user, session, *http.Request) + // user.UpdateCredential(credential) + // save user + // delete session + c.Redirect(http.StatusFound, "/dashboard") +} From 3b56c1e46cd3e61c080d4b1160aaf1503a274ba7 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Wed, 17 Sep 2025 05:30:28 -0400 Subject: [PATCH 2/9] Enabling all functionality for MFA option passkeys --- app/application.go | 8 +-- constants/constants.go | 2 + constants/permissions.go | 20 ++++++++ db/schema.sql | 1 + forms/lists.go | 1 + forms/two_factor_setup_form.go | 4 +- middleware/authorization_map.go | 4 ++ notes.md | 5 ++ pgmodels/user.go | 19 ++++++- views/users/choose_second_factor.html | 9 +++- views/users/passkey_login.html | 21 +++++++- views/users/passkey_register.html | 19 ++++++- web/webui/two_factor_controller.go | 74 +++++++++++++++++++++------ web/webui/two_factor_preferences.go | 4 ++ 14 files changed, 165 insertions(+), 26 deletions(-) diff --git a/app/application.go b/app/application.go index 1d3b86f8..8e097652 100644 --- a/app/application.go +++ b/app/application.go @@ -284,10 +284,10 @@ func initRoutes(router *gin.Engine) { webRoutes.GET("/ui_components", webui.ComponentsIndex) // User MFA - Passkeys - webRoutes.GET("/users/begin_passkey_registration", webui.UserBeginPasskeyRegistration) - webRoutes.GET("/users/finish_passkey_registration", webui.UserFinishPasskeyRegistration) - webRoutes.GET("/users/begin_login_with_passkey", webui.UserBeginLoginWithPasskey) - webRoutes.GET("/users/finish_login_with_passkey", webui.UserFinishLoginWithPasskey) + webRoutes.POST("/users/begin_passkey_registration", webui.UserBeginPasskeyRegistration) + webRoutes.POST("/users/finish_passkey_registration", webui.UserFinishPasskeyRegistration) + webRoutes.POST("/users/begin_login_with_passkey", webui.UserBeginLoginWithPasskey) + webRoutes.POST("/users/finish_login_with_passkey", webui.UserFinishLoginWithPasskey) } // Root goes to sign-in page, which is a web route, diff --git a/constants/constants.go b/constants/constants.go index 3f31cfb3..f34fa70e 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -86,6 +86,7 @@ const ( RoleSysAdmin = "admin" SecondFactorAuthy = "Authy" SecondFactorBackupCode = "Backup Code" + SecondFactorPasskey = "Passkey" SecondFactorSMS = "SMS" StageAvailableInS3 = "Available in S3" StageCleanup = "Cleanup" @@ -134,6 +135,7 @@ const ( TopicObjectRestore = "restore_object" TwoFactorAuthy = "onetouch" TwoFactorNone = "none" + TwoFactorPasskey = "passkey" TwoFactorSMS = "sms" ) diff --git a/constants/permissions.go b/constants/permissions.go index ac97f99c..2c44cf1f 100644 --- a/constants/permissions.go +++ b/constants/permissions.go @@ -67,11 +67,15 @@ const ( StorageRecordDelete = "StorageRecordDelete" StorageRecordRead = "StorageRecordRead" StorageRecordUpdate = "StorageRecordUpdate" + UserBeginLoginWithPasskey = "UserBeginLoginWithPasskey" + UserBeginPasskeyRegistration = "UserBeginPasskeyRegistration" UserComplete2FASetup = "UserComplete2FASetup" UserConfirmPhone = "UserConfirmPhone" UserCreate = "UserCreate" UserDelete = "UserDelete" UserDeleteSelf = "UserDeleteSelf" + UserFinishLoginWithPasskey = "UserFinishLoginWithPasskey" + UserFinishPasskeyRegistration = "UserFinishPasskeyRegistration" UserGenerateBackupCodes = "UserGenerateBackupCodes" UserInit2FASetup = "UserInit2FASetup" UserRead = "UserRead" @@ -146,11 +150,15 @@ var Permissions = []Permission{ StorageRecordDelete, StorageRecordRead, StorageRecordUpdate, + UserBeginLoginWithPasskey, + UserBeginPasskeyRegistration, UserComplete2FASetup, UserConfirmPhone, UserCreate, UserDelete, UserDeleteSelf, + UserFinishLoginWithPasskey, + UserFinishPasskeyRegistration, UserGenerateBackupCodes, UserInit2FASetup, UserRead, @@ -225,8 +233,12 @@ func initPermissions() { instUser[IntellectualObjectRestore] = true instUser[ReportRead] = true instUser[StorageRecordRead] = true + instUser[UserBeginLoginWithPasskey] = true + instUser[UserBeginPasskeyRegistration] = true instUser[UserComplete2FASetup] = true instUser[UserConfirmPhone] = true + instUser[UserFinishLoginWithPasskey] = true + instUser[UserFinishPasskeyRegistration] = true instUser[UserGenerateBackupCodes] = true instUser[UserInit2FASetup] = true instUser[UserReadSelf] = true @@ -263,10 +275,14 @@ func initPermissions() { instAdmin[IntellectualObjectRestore] = true instAdmin[ReportRead] = true instAdmin[StorageRecordRead] = true + instAdmin[UserBeginLoginWithPasskey] = true + instAdmin[UserBeginPasskeyRegistration] = true instAdmin[UserComplete2FASetup] = true instAdmin[UserConfirmPhone] = true instAdmin[UserCreate] = true instAdmin[UserDelete] = true + instAdmin[UserFinishLoginWithPasskey] = true + instAdmin[UserFinishPasskeyRegistration] = true instAdmin[UserGenerateBackupCodes] = true instAdmin[UserInit2FASetup] = true instAdmin[UserReadSelf] = true @@ -335,11 +351,15 @@ func initPermissions() { sysAdmin[StorageRecordDelete] = true sysAdmin[StorageRecordRead] = true sysAdmin[StorageRecordUpdate] = true + sysAdmin[UserBeginLoginWithPasskey] = true + sysAdmin[UserBeginPasskeyRegistration] = true sysAdmin[UserComplete2FASetup] = true sysAdmin[UserConfirmPhone] = true sysAdmin[UserCreate] = true sysAdmin[UserDeleteSelf] = true sysAdmin[UserDelete] = true + sysAdmin[UserFinishLoginWithPasskey] = true + sysAdmin[UserFinishPasskeyRegistration] = true sysAdmin[UserGenerateBackupCodes] = true sysAdmin[UserInit2FASetup] = true sysAdmin[UserReadSelf] = true diff --git a/db/schema.sql b/db/schema.sql index 0d324f5b..60a4b23e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -501,6 +501,7 @@ CREATE TABLE public.users ( grace_period timestamp NULL, awaiting_second_factor bool NOT NULL DEFAULT false, "role" varchar(50) NOT NULL DEFAULT 'none'::character varying, + -- encrypted_passkey_session varchar null, CONSTRAINT users_pkey PRIMARY KEY (id) ); CREATE INDEX index_users_on_authy_id ON public.users USING btree (authy_id); diff --git a/forms/lists.go b/forms/lists.go index 428fe180..34191da2 100644 --- a/forms/lists.go +++ b/forms/lists.go @@ -80,6 +80,7 @@ var StorageOptionList = []*ListOption{ var TwoFactorMethodList = []*ListOption{ {constants.TwoFactorNone, "None (Turn Off Two-Factor Authentication)", false}, + {constants.TwoFactorPasskey, "Passkey", false}, {constants.TwoFactorAuthy, "Authy OneTouch", false}, {constants.TwoFactorSMS, "Text Message", false}, } diff --git a/forms/two_factor_setup_form.go b/forms/two_factor_setup_form.go index bd9f8f42..24911756 100644 --- a/forms/two_factor_setup_form.go +++ b/forms/two_factor_setup_form.go @@ -20,8 +20,8 @@ func NewTwoFactorSetupForm(user *pgmodels.User) *TwoFactorSetupForm { func (f *TwoFactorSetupForm) init() { f.Fields["AuthyStatus"] = &Field{ Name: "AuthyStatus", - Label: "Preferred Method for Two-Factor Auth", - Placeholder: "", + Label: "Authy", + Placeholder: "Authy", ErrMsg: "Please choose your preferred method.", Options: TwoFactorMethodList, Attrs: map[string]string{ diff --git a/middleware/authorization_map.go b/middleware/authorization_map.go index 77c4b867..2332e6e9 100644 --- a/middleware/authorization_map.go +++ b/middleware/authorization_map.go @@ -114,6 +114,8 @@ var AuthMap = map[string]AuthMetadata{ "StorageRecordNew": {"StorageRecord", constants.StorageRecordCreate, "New Storage Record"}, "StorageRecordShow": {"StorageRecord", constants.StorageRecordRead, "Storage Record Detail"}, "StorageRecordUpdate": {"StorageRecord", constants.StorageRecordUpdate, "Update Storage Record"}, + "UserBeginLoginWithPasskey": {"User", constants.UserBeginLoginWithPasskey, "Login With Passkey"}, + "UserBeginPasskeyRegistration": {"User", constants.UserBeginPasskeyRegistration, "Set up a Passkey"}, "UserChangePassword": {"User", constants.UserUpdateSelf, "Change Password"}, "UserComplete2FASetup": {"User", constants.UserComplete2FASetup, "Setup Two-Factor Authentication"}, "UserConfirmPhone": {"User", constants.UserConfirmPhone, "Confirm Phone Number"}, @@ -121,6 +123,8 @@ var AuthMap = map[string]AuthMetadata{ "UserDelete": {"User", constants.UserDelete, "Deactivate User"}, "UserDeleteSelf": {"User", constants.UserDeleteSelf, "Deactivate Your Account"}, "UserEdit": {"User", constants.UserUpdate, "Edit User"}, + "UserFinishLoginWithPasskey": {"User", constants.UserFinishLoginWithPasskey, "Complete Passkey Login"}, + "UserFinishPasskeyRegistration": {"User", constants.UserFinishPasskeyRegistration, "Complete Passkey Setup"}, "UserGenerateBackupCodes": {"User", constants.UserGenerateBackupCodes, "Create Two-Factor Backup Codes"}, "UserGetAPIKey": {"User", constants.UserUpdateSelf, "Generate API Key"}, "UserIndex": {"User", constants.UserRead, "Users"}, diff --git a/notes.md b/notes.md index 20d34ab4..9c16ec2c 100644 --- a/notes.md +++ b/notes.md @@ -102,11 +102,14 @@ Remember, depdenency hell and mountains of garbage code are only one npm package ### Login * Email/password login +* Two-factor passkey * Two-factor text/sms * Two-factor Authy To ensure users won't have to change their passwords when moving from the Rails app, implement the same password encryption scheme as Devise. The scheme is described [here](https://www.freecodecamp.org/news/how-does-devise-keep-your-passwords-safe-d367f6e816eb/), and the [Go bcrypt library](https://pkg.go.dev/golang.org/x/crypto/bcrypt) should be able to support it. +We now support logging in using a device passkey as a second factor of authentication. The user's browser and device must be compatible with passkeys to use this feature. + For two-factor auth, since we're already using Authy, try the [Go Client for Authy](https://github.com/dcu/go-authy). ### Edit @@ -345,6 +348,8 @@ The term "items" below refers to Intellectual Objects, Generic Files, Checksums, # Two Factor Authentication +Passkeys are supported as a second factor of authentication. Note that your browser and device must be compatible with passkeys in order to use them. Passkeys are tied to the device they are configured on. + Current Pharos users who have enabled two-factor authentication receive one-time passwords through SMS or push notifications through Authy OneTouch. These methods were chosen after long discussion with depositors and we cannot change them without another long discussion. So for now, we're sticking with these two. Notes on two-factor setup and workflow have grown large enoug to warrant their own document. See [Two Factor Notes](two_factor_notes.md). diff --git a/pgmodels/user.go b/pgmodels/user.go index 0917ced3..2b4f0b21 100644 --- a/pgmodels/user.go +++ b/pgmodels/user.go @@ -197,6 +197,14 @@ type User struct { // Institution is where they lock you up after you've spent too much // time trying to figure out the old Rails code. Institution *Institution `json:"institution" pg:"rel:has-one"` + + // EncryptedPasskeySession saves session data for use with passkey + // authentication. The passkey is used as a possible second factor of + // authentication for Registry. + EncryptedPasskeySession string `json:"-" form:"-" pg:"encrypted_passkey_session"` + + // EncryptedPasskeyCredential saves the user's device passkey + EncryptedPasskeyCredential string `json:"-" form:"-" pg:"encrypted_passkey_credential"` } // UserByID returns the institution with the specified id. @@ -377,6 +385,12 @@ func (user *User) IsAuthyOneTouchUser() bool { return user.IsTwoFactorUser() && (user.AuthyStatus == constants.TwoFactorAuthy) } +// IsPasskeyUser returns true if the user has enabled Passkeys +// for two-factor login. +func (user *User) IsPasskeyUser() bool { + return user.IsTwoFactorUser() && (user.AuthyStatus == constants.TwoFactorPasskey) +} + // IsTwoFactorUser returns true if this user has enabled and confirmed // two factor authentication. // @@ -401,7 +415,10 @@ func (user *User) TwoFactorMethod() string { if user.IsSMSUser() { return constants.TwoFactorSMS } - return constants.TwoFactorAuthy + if user.IsAuthyOneTouchUser() { + return constants.TwoFactorAuthy + } + return constants.TwoFactorPasskey } // CreateOTPToken creates a new one-time password token, typically diff --git a/views/users/choose_second_factor.html b/views/users/choose_second_factor.html index 073d8952..105f3640 100644 --- a/views/users/choose_second_factor.html +++ b/views/users/choose_second_factor.html @@ -25,6 +25,9 @@

Multi-Factor Authentication Required

diff --git a/views/users/passkey_register.html b/views/users/passkey_register.html index b9c0ef82..c9448ce7 100644 --- a/views/users/passkey_register.html +++ b/views/users/passkey_register.html @@ -19,6 +19,7 @@

Passkey Registration

+ {{ template "forms/csrf_token.html" . }}
diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index b30418c3..eb680090 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -1,8 +1,12 @@ package webui import ( + "crypto/rand" + "encoding/base64" "fmt" "net/http" + "strconv" + "strings" "time" "github.com/APTrust/registry/common" @@ -10,9 +14,58 @@ import ( "github.com/APTrust/registry/forms" "github.com/APTrust/registry/helpers" "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" "github.com/stretchr/stew/slice" ) +// Passkey user model +type PasskeyUser struct { + ID []byte + DisplayName string + Name string + Credentials []webauthn.Credential +} + +func (o PasskeyUser) WebAuthnCredentials() []webauthn.Credential { + return o.Credentials +} + +func (o PasskeyUser) WebAuthnDisplayName() string { + return o.DisplayName +} + +func (o PasskeyUser) WebAuthnID() []byte { + return o.ID +} + +func (o PasskeyUser) WebAuthnName() string { + return o.Name +} + +func GenSessionID() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(b), nil +} + +type PasskeySession struct { + Challenge string + RelyingPartyID string + UserID []byte + AllowedCredentialIDs [][]byte + Expires time.Time + + UserVerification protocol.UserVerificationRequirement + Extensions protocol.AuthenticationExtensions + CredParams []protocol.CredentialParameter + Mediation protocol.CredentialMediationRequirement +} + // UserTwoFactorChoose shows a list of radio button options so a user // can choose their two-factor auth method (Authy, Backup Code, SMS). // We show this page after a user has entered their email and password, @@ -407,15 +460,29 @@ func OTPTokenIsExpired(tokenSentAt time.Time) bool { func UserBeginPasskeyRegistration(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser - options, session, err := common.Context().WebAuthn.BeginRegistration(user) // webauthn.User with Id, Name, DisplayName - if err != nil { - return err + webauthnuser := PasskeyUser{ID: []byte(strconv.FormatInt(user.ID, 10)), DisplayName: user.Name, Name: user.Name} + _, session, err := common.Context().WebAuthn.BeginRegistration(webauthnuser) // webauthn.User with Id, Name, DisplayName + if AbortIfError(c, err) { + c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + return } // Save session to DB table - user.EncryptedPasskeySession = session + challenge := string(session.Challenge) + relyingPartyID := string(session.RelyingPartyID) + userID := string(session.UserID) + allowedCredentialsIDs := string(session.AllowedCredentialIDs[0]) + expires := session.Expires.String() + webauthnsession := challenge + "~" + relyingPartyID + "~" + userID + "~" + allowedCredentialsIDs + "~" + expires + user.EncryptedPasskeySession = webauthnsession + user.Save() - req.TemplateData["pubKey"] = options.publicKey - req.TemplateData["sessionKey"] = options.sessionKey // header? + // req.TemplateData["pubKey"] = options + sessionID, err := GenSessionID() + if AbortIfError(c, err) { + c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + return + } + req.TemplateData["sessionKey"] = sessionID // options.sessionKey // header? c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) } @@ -426,12 +493,21 @@ func UserFinishPasskeyRegistration(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser session := user.EncryptedPasskeySession - credential, err := common.Context().WebAuthn.FinishRegistration(user, session, req) // webauthn.User and webauthn.SessionData - if err != nil { - return err + webauthnuser := PasskeyUser{ID: []byte(string(user.ID)), DisplayName: user.Name, Name: user.Name} + sessionparts := strings.Split(session, "~") + bytestr := []byte(sessionparts[3]) + allowedCreds := [][]byte{[]byte(bytestr)} + layout := "2006-01-02 15:04:05" + expire, _ := time.Parse(layout, sessionparts[4]) + // webauthnsession := PasskeySession{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} + wasession := webauthn.SessionData{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} + _, err := common.Context().WebAuthn.FinishRegistration(webauthnuser, wasession, c.Request) // webauthn.User and webauthn.SessionData + if AbortIfError(c, err) { + return } - - user.EncryptedPasskeyCredential = credential + // GET credential from FinishRegistration above + //webauthncredential := webauthn.Credential{} + // user.EncryptedPasskeyCredential = webauthncredential // credential user.EncryptedPasskeySession = "" user.Save() @@ -442,14 +518,27 @@ func UserFinishPasskeyRegistration(c *gin.Context) { func UserBeginLoginWithPasskey(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser - options, session, err := common.Context().WebAuthn.BeginLogin(user) - if err != nil { - return err + webauthnuser := PasskeyUser{ID: []byte(strconv.FormatInt(user.ID, 10)), DisplayName: user.Name, Name: user.Name} + _, session, err := common.Context().WebAuthn.BeginLogin(webauthnuser) + if AbortIfError(c, err) { + return + } + sessionID, err := GenSessionID() + if AbortIfError(c, err) { + return } // Save session to DB table - user.EncryptedPasskeySession = session + challenge := string(session.Challenge) + relyingPartyID := string(session.RelyingPartyID) + userID := string(session.UserID) + allowedCredentialsIDs := string(session.AllowedCredentialIDs[0]) + expires := session.Expires.String() + webauthnsession := challenge + "~" + relyingPartyID + "~" + userID + "~" + allowedCredentialsIDs + "~" + expires + user.EncryptedPasskeySession = webauthnsession + + user.EncryptedPasskeySession = webauthnsession user.Save() - req.TemplateData["pubKey"] = options + req.TemplateData["sessionKey"] = sessionID // options.sessionKey // header? c.HTML(http.StatusOK, "users/passkey_login.html", req.TemplateData) } @@ -458,16 +547,24 @@ func UserFinishLoginWithPasskey(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser session := user.EncryptedPasskeySession - credential, err := common.Context().WebAuthn.FinishLogin(user, session, req) - if err != nil { - return err - } - - if credential.Authenticator.CloneWarning { - req.TemplateData["cloneWarningMessage"] = "Error: CloneWarning" + // session := user.EncryptedPasskeySession + webauthnuser := PasskeyUser{ID: []byte(string(user.ID)), DisplayName: user.Name, Name: user.Name} + sessionparts := strings.Split(session, "~") + bytestr := []byte(sessionparts[3]) + allowedCreds := [][]byte{[]byte(bytestr)} + layout := "2006-01-02 15:04:05" + expire, _ := time.Parse(layout, sessionparts[4]) + // webauthnsession := PasskeySession{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} + wasession := webauthn.SessionData{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} + _, err := common.Context().WebAuthn.FinishLogin(webauthnuser, wasession, c.Request) + if AbortIfError(c, err) { + return } + // if credential.Authenticator.CloneWarning { + // req.TemplateData["cloneWarningMessage"] = "Error: CloneWarning" + // } - user.EncryptedPasskeyCredential = credential + // user.EncryptedPasskeyCredential = credential user.EncryptedPasskeySession = "" user.Save() From f818fb81986d4adb0b359c1ad5639e263f7ced3e Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Tue, 23 Sep 2025 02:51:49 -0400 Subject: [PATCH 4/9] Debug --- web/webui/two_factor_controller.go | 93 ++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index eb680090..9b97ac95 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "net/http" + "os" "strconv" "strings" "time" @@ -24,22 +25,48 @@ type PasskeyUser struct { ID []byte DisplayName string Name string - Credentials []webauthn.Credential + creds []webauthn.Credential } -func (o PasskeyUser) WebAuthnCredentials() []webauthn.Credential { - return o.Credentials +type PasskeyRealUser interface { + webauthn.User + AddCredential(*webauthn.Credential) + UpdateCredential(*webauthn.Credential) } -func (o PasskeyUser) WebAuthnDisplayName() string { +func ReturnTruePasskeyUser(u string) PasskeyRealUser { + return &PasskeyUser{ID: []byte(u), DisplayName: u, Name: u} +} + +func (o *PasskeyUser) WebAuthnIcon() string { + return "https://pics.com/avatar.png" +} + +func (o *PasskeyUser) AddCredential(credential *webauthn.Credential) { + o.creds = append(o.creds, *credential) +} + +func (o *PasskeyUser) UpdateCredential(credential *webauthn.Credential) { + for i, c := range o.creds { + if string(c.ID) == string(credential.ID) { + o.creds[i] = *credential + } + } +} + +func (o *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { + return o.creds +} + +func (o *PasskeyUser) WebAuthnDisplayName() string { return o.DisplayName } -func (o PasskeyUser) WebAuthnID() []byte { +func (o *PasskeyUser) WebAuthnID() []byte { return o.ID } -func (o PasskeyUser) WebAuthnName() string { +func (o *PasskeyUser) WebAuthnName() string { return o.Name } @@ -460,29 +487,33 @@ func OTPTokenIsExpired(tokenSentAt time.Time) bool { func UserBeginPasskeyRegistration(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser - webauthnuser := PasskeyUser{ID: []byte(strconv.FormatInt(user.ID, 10)), DisplayName: user.Name, Name: user.Name} - _, session, err := common.Context().WebAuthn.BeginRegistration(webauthnuser) // webauthn.User with Id, Name, DisplayName - if AbortIfError(c, err) { - c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) - return - } - // Save session to DB table - challenge := string(session.Challenge) - relyingPartyID := string(session.RelyingPartyID) - userID := string(session.UserID) - allowedCredentialsIDs := string(session.AllowedCredentialIDs[0]) - expires := session.Expires.String() - webauthnsession := challenge + "~" + relyingPartyID + "~" + userID + "~" + allowedCredentialsIDs + "~" + expires - user.EncryptedPasskeySession = webauthnsession - - user.Save() - // req.TemplateData["pubKey"] = options - sessionID, err := GenSessionID() - if AbortIfError(c, err) { - c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) - return + webauthnuser := ReturnTruePasskeyUser(user.Name) + if common.Context().WebAuthn != nil { + _, session, err := common.Context().WebAuthn.BeginRegistration(webauthnuser) // webauthn.User with Id, Name, DisplayName + d1 := []byte(err.Error()) + _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) + if AbortIfError(c, err) { + c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + return + } + // Save session to DB table + challenge := string(session.Challenge) + relyingPartyID := string(session.RelyingPartyID) + userID := string(session.UserID) + allowedCredentialsIDs := string(session.AllowedCredentialIDs[0]) + expires := session.Expires.String() + webauthnsession := challenge + "~" + relyingPartyID + "~" + userID + "~" + allowedCredentialsIDs + "~" + expires + user.EncryptedPasskeySession = webauthnsession + + user.Save() + // req.TemplateData["pubKey"] = options + sessionID, err := GenSessionID() + if AbortIfError(c, err) { + c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + return + } + req.TemplateData["sessionKey"] = sessionID // options.sessionKey // header? } - req.TemplateData["sessionKey"] = sessionID // options.sessionKey // header? c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) } @@ -493,7 +524,7 @@ func UserFinishPasskeyRegistration(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser session := user.EncryptedPasskeySession - webauthnuser := PasskeyUser{ID: []byte(string(user.ID)), DisplayName: user.Name, Name: user.Name} + webauthnuser := &PasskeyUser{ID: []byte(user.Name), DisplayName: user.Name, Name: user.Name} sessionparts := strings.Split(session, "~") bytestr := []byte(sessionparts[3]) allowedCreds := [][]byte{[]byte(bytestr)} @@ -518,7 +549,7 @@ func UserFinishPasskeyRegistration(c *gin.Context) { func UserBeginLoginWithPasskey(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser - webauthnuser := PasskeyUser{ID: []byte(strconv.FormatInt(user.ID, 10)), DisplayName: user.Name, Name: user.Name} + webauthnuser := &PasskeyUser{ID: []byte(strconv.FormatInt(user.ID, 10)), DisplayName: user.Name, Name: user.Name} _, session, err := common.Context().WebAuthn.BeginLogin(webauthnuser) if AbortIfError(c, err) { return @@ -548,7 +579,7 @@ func UserFinishLoginWithPasskey(c *gin.Context) { user := req.CurrentUser session := user.EncryptedPasskeySession // session := user.EncryptedPasskeySession - webauthnuser := PasskeyUser{ID: []byte(string(user.ID)), DisplayName: user.Name, Name: user.Name} + webauthnuser := &PasskeyUser{ID: []byte(string(user.ID)), DisplayName: user.Name, Name: user.Name} sessionparts := strings.Split(session, "~") bytestr := []byte(sessionparts[3]) allowedCreds := [][]byte{[]byte(bytestr)} From 308ed4a985dd2456ed5e6a2d8050eb4ec928ab0a Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Tue, 23 Sep 2025 03:58:28 -0400 Subject: [PATCH 5/9] Fixing null issues --- views/users/passkey_register.html | 2 +- web/webui/two_factor_controller.go | 36 +++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/views/users/passkey_register.html b/views/users/passkey_register.html index c9448ce7..d0cc2440 100644 --- a/views/users/passkey_register.html +++ b/views/users/passkey_register.html @@ -18,7 +18,7 @@

Passkey Registration

Click below to add a passkey.

- +
+
+ + + {{ template "forms/csrf_token.html" . }} +
+ + + + + + + + +{{ if not .showAsModal }} + {{ template "shared/_footer.html" .}} +{{ end }} + + +{{ end }} diff --git a/views/users/passkey_register.html b/views/users/passkey_register.html index d0cc2440..44e9acf4 100644 --- a/views/users/passkey_register.html +++ b/views/users/passkey_register.html @@ -17,8 +17,9 @@

Passkey Registration

@@ -26,8 +27,20 @@

Passkey Registration

- + {{ if not .showAsModal }} diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index ada067f4..e164fcca 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -1,9 +1,12 @@ package webui import ( + "bytes" "crypto/rand" "encoding/base64" + "encoding/json" "fmt" + "io" "net/http" "os" "strconv" @@ -278,8 +281,9 @@ func UserComplete2FASetup(c *gin.Context) { } if prefs.UsePasskey() { - UserBeginPasskeyRegistration(c) - return + c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + // UserBeginPasskeyRegistration(c) + // return } if prefs.UseAuthy() { @@ -488,10 +492,11 @@ func UserBeginPasskeyRegistration(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser webauthnuser := ReturnTruePasskeyUser(user.Name) + resstring := "" if common.Context().WebAuthn != nil { options, session, err := common.Context().WebAuthn.BeginRegistration(webauthnuser) // webauthn.User with Id, Name, DisplayName if AbortIfError(c, err) { - c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + c.HTML(http.StatusOK, "users/my_account.html", req.TemplateData) } challenge := "" @@ -518,17 +523,28 @@ func UserBeginPasskeyRegistration(c *gin.Context) { user.Save() - d1 := []byte("Saved user") - _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + err = encoder.Encode(options) + if AbortIfError(c, err) { + c.HTML(http.StatusOK, "users/my_account.html", req.TemplateData) + } + reader := io.Reader(&buf) + res, err := io.ReadAll(reader) + if AbortIfError(c, err) { + c.HTML(http.StatusOK, "users/my_account.html", req.TemplateData) + } + resstring = string(res) - req.TemplateData["pubKey"] = options sessionID, err := GenSessionID() if AbortIfError(c, err) { - c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + c.HTML(http.StatusOK, "users/my_account.html", req.TemplateData) } req.TemplateData["sessionKey"] = sessionID // options.sessionKey // header? } - c.HTML(http.StatusOK, "users/passkey_register.html", req.TemplateData) + req.TemplateData["public_key"] = resstring + c.HTML(http.StatusOK, "users/passkey_finish_registration.html", req.TemplateData) + // c.JSON(http.StatusOK, json.RawMessage(resstring)) } // To run in order to finish device registration with a passkey. @@ -537,6 +553,15 @@ func UserFinishPasskeyRegistration(c *gin.Context) { // Get Session from DB req := NewRequest(c) user := req.CurrentUser + + d1 := []byte(c.PostForm("attestation")) + _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) + + newAttestation := []byte(c.PostForm("attestation")) + c.Request.Body = io.NopCloser(bytes.NewBuffer(newAttestation)) + c.Request.ContentLength = int64(len(newAttestation)) + c.Request.Header.Add("Content-Type", "application/json") + session := user.EncryptedPasskeySession webauthnuser := &PasskeyUser{ID: []byte(user.Name), DisplayName: user.Name, Name: user.Name} sessionparts := strings.Split(session, "~") @@ -546,17 +571,23 @@ func UserFinishPasskeyRegistration(c *gin.Context) { expire, _ := time.Parse(layout, sessionparts[4]) // webauthnsession := PasskeySession{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} wasession := webauthn.SessionData{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} + _, err := common.Context().WebAuthn.FinishRegistration(webauthnuser, wasession, c.Request) // webauthn.User and webauthn.SessionData + + d1 = []byte(err.Error()) + _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) + if AbortIfError(c, err) { return } + // GET credential from FinishRegistration above - //webauthncredential := webauthn.Credential{} // user.EncryptedPasskeyCredential = webauthncredential // credential + user.EncryptedPasskeySession = "" user.Save() - c.Redirect(http.StatusFound, "/dashboard") + c.Redirect(http.StatusFound, "/users/my_account") } // To run in order to begin logging in with a passkey. From d47421f3c5908897216e3958c26e9595f5b2e089 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Wed, 29 Oct 2025 22:54:58 -0400 Subject: [PATCH 7/9] Troubleshooting attestation issue by changing versions used on passkey libs --- go.mod | 2 +- go.sum | 2 ++ static/js/simplewebauthn.index.umd.min.js | 4 ++-- web/webui/two_factor_controller.go | 18 +++++++++++++----- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 8004f150..9e63ec6b 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-pg/pg/v10 v10.12.0 github.com/go-redis/redis/v7 v7.4.1 - github.com/go-webauthn/webauthn v0.14.0 + github.com/go-webauthn/webauthn v0.13.0 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.1 github.com/jinzhu/copier v0.3.0 diff --git a/go.sum b/go.sum index 7b59e099..cff15224 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/go-redis/redis/v7 v7.4.1/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRf github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4= github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs= +github.com/go-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y= +github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs= github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0= github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k= github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE= diff --git a/static/js/simplewebauthn.index.umd.min.js b/static/js/simplewebauthn.index.umd.min.js index 7a3cdff5..1af4f403 100644 --- a/static/js/simplewebauthn.index.umd.min.js +++ b/static/js/simplewebauthn.index.umd.min.js @@ -1,2 +1,2 @@ -/* [@simplewebauthn/browser@13.2.0] */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let r="";for(const e of t)r+=String.fromCharCode(e);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function r(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;ee};function i(e){const{id:t}=e;return{...e,id:r(t),transports:e.transports}}function a(e){return"localhost"===e||/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(e)}class s extends Error{constructor({message:e,code:t,cause:r,name:n}){super(e,{cause:r}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??r.name,this.code=t}}const l=new class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){const e=new Error("Cancelling existing WebAuthn API call for new one");e.name="AbortError",this.controller.abort(e)}const e=new AbortController;return this.controller=e,e.signal}cancelCeremony(){if(this.controller){const e=new Error("Manually cancelling existing WebAuthn API call");e.name="AbortError",this.controller.abort(e),this.controller=void 0}}},c=["cross-platform","platform"];function u(e){if(e&&!(c.indexOf(e)<0))return e}function d(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.\n`,t)}function h(){if(!n())return p.stubThis(new Promise((e=>e(!1))));const e=globalThis.PublicKeyCredential;return void 0===e?.isConditionalMediationAvailable?p.stubThis(new Promise((e=>e(!1)))):p.stubThis(e.isConditionalMediationAvailable())}const p={stubThis:e=>e};e.WebAuthnAbortService=l,e.WebAuthnError=s,e._browserSupportsWebAuthnAutofillInternals=p,e._browserSupportsWebAuthnInternals=o,e.base64URLStringToBuffer=r,e.browserSupportsWebAuthn=n,e.browserSupportsWebAuthnAutofill=h,e.bufferToBase64URLString=t,e.platformAuthenticatorIsAvailable=function(){return n()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise((e=>e(!1)))},e.startAuthentication=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useBrowserAutofill:c=!1,verifyBrowserAutofillInput:d=!0}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");let p;0!==o.allowCredentials?.length&&(p=o.allowCredentials?.map(i));const f={...o,challenge:r(o.challenge),allowCredentials:p},b={};if(c){if(!await h())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&d)throw Error('No with "webauthn" as the only or last value in its `autocomplete` attribute was detected');b.mediation="conditional",f.allowCredentials=[]}let R;b.publicKey=f,b.signal=l.createNewAbortSignal();try{R=await navigator.credentials.get(b)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rpId!==t)return new s({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:b})}if(!R)throw new Error("Authentication was not completed");const{id:g,rawId:w,response:A,type:E}=R;let m;return A.userHandle&&(m=t(A.userHandle)),{id:g,rawId:t(w),response:{authenticatorData:t(A.authenticatorData),clientDataJSON:t(A.clientDataJSON),signature:t(A.signature),userHandle:m},type:E,clientExtensionResults:R.getClientExtensionResults(),authenticatorAttachment:u(R.authenticatorAttachment)}},e.startRegistration=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useAutoRegister:c=!1}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");const h={...o,challenge:r(o.challenge),user:{...o.user,id:r(o.user.id)},excludeCredentials:o.excludeCredentials?.map(i)},p={};let f;c&&(p.mediation="conditional"),p.publicKey=h,p.signal=l.createNewAbortSignal();try{f=await navigator.credentials.create(p)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if("ConstraintError"===e.name){if(!0===r.authenticatorSelection?.requireResidentKey)return new s({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if("conditional"===t.mediation&&"required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if("required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if("InvalidStateError"===e.name)return new s({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("NotSupportedError"===e.name)return 0===r.pubKeyCredParams.filter((e=>"public-key"===e.type)).length?new s({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new s({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rp.id!==t)return new s({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new s({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:p})}if(!f)throw new Error("Registration was not completed");const{id:b,rawId:R,response:g,type:w}=f;let A,E,m,y;if("function"==typeof g.getTransports&&(A=g.getTransports()),"function"==typeof g.getPublicKeyAlgorithm)try{E=g.getPublicKeyAlgorithm()}catch(e){d("getPublicKeyAlgorithm()",e)}if("function"==typeof g.getPublicKey)try{const e=g.getPublicKey();null!==e&&(m=t(e))}catch(e){d("getPublicKey()",e)}if("function"==typeof g.getAuthenticatorData)try{y=t(g.getAuthenticatorData())}catch(e){d("getAuthenticatorData()",e)}return{id:b,rawId:t(R),response:{attestationObject:t(g.attestationObject),clientDataJSON:t(g.clientDataJSON),transports:A,publicKeyAlgorithm:E,publicKey:m,authenticatorData:y},type:w,clientExtensionResults:f.getClientExtensionResults(),authenticatorAttachment:u(f.authenticatorAttachment)}}})); +/* [@simplewebauthn/browser@10.0.0] */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let r="";for(const e of t)r+=String.fromCharCode(e);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function r(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;ee(!1)));const e=window.PublicKeyCredential;return void 0===e.isConditionalMediationAvailable?new Promise((e=>e(!1))):e.isConditionalMediationAvailable()}e.WebAuthnAbortService=s,e.WebAuthnError=a,e.base64URLStringToBuffer=r,e.browserSupportsWebAuthn=n,e.browserSupportsWebAuthnAutofill=d,e.bufferToBase64URLString=t,e.platformAuthenticatorIsAvailable=function(){return n()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise((e=>e(!1)))},e.startAuthentication=async function(e,c=!1){if(!n())throw new Error("WebAuthn is not supported in this browser");let u;0!==e.allowCredentials?.length&&(u=e.allowCredentials?.map(o));const h={...e,challenge:r(e.challenge),allowCredentials:u},f={};if(c){if(!await d())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1)throw Error('No with "webauthn" as the only or last value in its `autocomplete` attribute was detected');f.mediation="conditional",h.allowCredentials=[]}let p;f.publicKey=h,f.signal=s.createNewAbortSignal();try{p=await navigator.credentials.get(f)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new a({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new a({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=window.location.hostname;if(!i(t))return new a({message:`${window.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rpId!==t)return new a({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new a({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:f})}if(!p)throw new Error("Authentication was not completed");const{id:R,rawId:w,response:E,type:g}=p;let A;return E.userHandle&&(A=t(E.userHandle)),{id:R,rawId:t(w),response:{authenticatorData:t(E.authenticatorData),clientDataJSON:t(E.clientDataJSON),signature:t(E.signature),userHandle:A},type:g,clientExtensionResults:p.getClientExtensionResults(),authenticatorAttachment:l(p.authenticatorAttachment)}},e.startRegistration=async function(e){if(!n())throw new Error("WebAuthn is not supported in this browser");const c={publicKey:{...e,challenge:r(e.challenge),user:{...e.user,id:r(e.user.id)},excludeCredentials:e.excludeCredentials?.map(o)}};let d;c.signal=s.createNewAbortSignal();try{d=await navigator.credentials.create(c)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new a({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if("ConstraintError"===e.name){if(!0===r.authenticatorSelection?.requireResidentKey)return new a({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if("required"===r.authenticatorSelection?.userVerification)return new a({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if("InvalidStateError"===e.name)return new a({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if("NotAllowedError"===e.name)return new a({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("NotSupportedError"===e.name)return 0===r.pubKeyCredParams.filter((e=>"public-key"===e.type)).length?new a({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new a({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=window.location.hostname;if(!i(t))return new a({message:`${window.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rp.id!==t)return new a({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new a({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new a({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:c})}if(!d)throw new Error("Registration was not completed");const{id:h,rawId:f,response:p,type:R}=d;let w,E,g,A;if("function"==typeof p.getTransports&&(w=p.getTransports()),"function"==typeof p.getPublicKeyAlgorithm)try{E=p.getPublicKeyAlgorithm()}catch(e){u("getPublicKeyAlgorithm()",e)}if("function"==typeof p.getPublicKey)try{const e=p.getPublicKey();null!==e&&(g=t(e))}catch(e){u("getPublicKey()",e)}if("function"==typeof p.getAuthenticatorData)try{A=t(p.getAuthenticatorData())}catch(e){u("getAuthenticatorData()",e)}return{id:h,rawId:t(f),response:{attestationObject:t(p.attestationObject),clientDataJSON:t(p.clientDataJSON),transports:w,publicKeyAlgorithm:E,publicKey:g,authenticatorData:A},type:R,clientExtensionResults:d.getClientExtensionResults(),authenticatorAttachment:l(d.authenticatorAttachment)}},Object.defineProperty(e,"__esModule",{value:!0})})); diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index e164fcca..9e932638 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/http/httputil" "os" "strconv" "strings" @@ -563,16 +564,23 @@ func UserFinishPasskeyRegistration(c *gin.Context) { c.Request.Header.Add("Content-Type", "application/json") session := user.EncryptedPasskeySession - webauthnuser := &PasskeyUser{ID: []byte(user.Name), DisplayName: user.Name, Name: user.Name} + webauthnuser := ReturnTruePasskeyUser(user.Name) sessionparts := strings.Split(session, "~") - bytestr := []byte(sessionparts[3]) - allowedCreds := [][]byte{[]byte(bytestr)} + // bytestr := []byte(sessionparts[3]) + // allowedCreds := [][]byte{[]byte(bytestr)} layout := "2006-01-02 15:04:05" expire, _ := time.Parse(layout, sessionparts[4]) // webauthnsession := PasskeySession{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} - wasession := webauthn.SessionData{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} + wasession := webauthn.SessionData{Challenge: sessionparts[0], UserID: []byte(sessionparts[2]), Expires: expire, UserVerification: "preferred"} + + //d2, err := json.Marshal(webauthnuser) + //_ = os.WriteFile("/tmp/passkeyerrs", d2, 0644) + //d2, err := json.Marshal(wasession) + //_ = os.WriteFile("/tmp/passkeyerrs", d2, 0644) + d3, err := httputil.DumpRequest(c.Request, true) + _ = os.WriteFile("/tmp/passkeyerrs", d3, 0644) - _, err := common.Context().WebAuthn.FinishRegistration(webauthnuser, wasession, c.Request) // webauthn.User and webauthn.SessionData + _, err = common.Context().WebAuthn.FinishRegistration(webauthnuser, wasession, c.Request) // webauthn.User and webauthn.SessionData d1 = []byte(err.Error()) _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) From 4b35db7d8f4112ce848c19bffc024b3a41a73dab Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Wed, 19 Nov 2025 10:44:07 -0500 Subject: [PATCH 8/9] Downgraded passkey libs to stable versions due to a breaking bug in the newer versions --- go.mod | 12 ++++++------ go.sum | 9 +++++++++ web/webui/two_factor_controller.go | 12 ++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 9e63ec6b..f108ba5f 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-pg/pg/v10 v10.12.0 github.com/go-redis/redis/v7 v7.4.1 - github.com/go-webauthn/webauthn v0.13.0 + github.com/go-webauthn/webauthn v0.10.2 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.1 github.com/jinzhu/copier v0.3.0 @@ -24,7 +24,7 @@ require ( github.com/spf13/viper v1.7.1 github.com/stretchr/stew v0.0.0-20130812190256-80ef0842b48b github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.42.0 + golang.org/x/crypto v0.41.0 golang.org/x/text v0.29.0 ) @@ -45,16 +45,16 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/go-webauthn/x v0.1.25 // indirect + github.com/go-webauthn/x v0.1.9 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gojektech/heimdall v5.0.2+incompatible // indirect github.com/gojektech/valkyrie v0.0.0-20190210220504-8f62c1e7ba45 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/protobuf v1.5.0 // indirect github.com/golang/snappy v0.0.3 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/go-tpm v0.9.5 // indirect + github.com/google/go-tpm v0.9.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/imkira/go-interpol v1.1.0 // indirect @@ -102,7 +102,7 @@ require ( github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.35.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/ini.v1 v1.51.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index cff15224..ad12cd6b 100644 --- a/go.sum +++ b/go.sum @@ -424,6 +424,10 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -470,6 +474,7 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -512,6 +517,10 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index 9e932638..73669f02 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -94,7 +94,7 @@ type PasskeySession struct { UserVerification protocol.UserVerificationRequirement Extensions protocol.AuthenticationExtensions CredParams []protocol.CredentialParameter - Mediation protocol.CredentialMediationRequirement + // Mediation protocol.CredentialMediationRequirement } // UserTwoFactorChoose shows a list of radio button options so a user @@ -509,9 +509,9 @@ func UserBeginPasskeyRegistration(c *gin.Context) { if session.Challenge != "" { challenge = string(session.Challenge) } - if session.RelyingPartyID != "" { + /*if session.RelyingPartyID != "" { relyingPartyID = string(session.RelyingPartyID) - } + }*/ if session.UserID != nil { userID = string(session.UserID) } @@ -613,11 +613,11 @@ func UserBeginLoginWithPasskey(c *gin.Context) { } // Save session to DB table challenge := string(session.Challenge) - relyingPartyID := string(session.RelyingPartyID) + // relyingPartyID := string(session.RelyingPartyID) userID := string(session.UserID) allowedCredentialsIDs := string(session.AllowedCredentialIDs[0]) expires := session.Expires.String() - webauthnsession := challenge + "~" + relyingPartyID + "~" + userID + "~" + allowedCredentialsIDs + "~" + expires + webauthnsession := challenge + "~" + "" + "~" + userID + "~" + allowedCredentialsIDs + "~" + expires user.EncryptedPasskeySession = webauthnsession user.EncryptedPasskeySession = webauthnsession @@ -639,7 +639,7 @@ func UserFinishLoginWithPasskey(c *gin.Context) { layout := "2006-01-02 15:04:05" expire, _ := time.Parse(layout, sessionparts[4]) // webauthnsession := PasskeySession{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} - wasession := webauthn.SessionData{Challenge: sessionparts[0], RelyingPartyID: sessionparts[1], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} + wasession := webauthn.SessionData{Challenge: sessionparts[0], UserID: []byte(sessionparts[2]), AllowedCredentialIDs: allowedCreds, Expires: expire} _, err := common.Context().WebAuthn.FinishLogin(webauthnuser, wasession, c.Request) if AbortIfError(c, err) { return From b049e729d00590ab6653546b45aa4f700408cfb3 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Thu, 11 Dec 2025 13:37:45 -0500 Subject: [PATCH 9/9] Issues with finishing registration checkpoint --- pgmodels/user.go | 2 +- web/webui/two_factor_controller.go | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pgmodels/user.go b/pgmodels/user.go index 5c2f0040..2b4f0b21 100644 --- a/pgmodels/user.go +++ b/pgmodels/user.go @@ -204,7 +204,7 @@ type User struct { EncryptedPasskeySession string `json:"-" form:"-" pg:"encrypted_passkey_session"` // EncryptedPasskeyCredential saves the user's device passkey - // EncryptedPasskeyCredential string `json:"-" form:"-" pg:"encrypted_passkey_credential"` + EncryptedPasskeyCredential string `json:"-" form:"-" pg:"encrypted_passkey_credential"` } // UserByID returns the institution with the specified id. diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index 73669f02..fc580b48 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/http/httputil" "os" "strconv" "strings" @@ -555,8 +554,8 @@ func UserFinishPasskeyRegistration(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser - d1 := []byte(c.PostForm("attestation")) - _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) + // d1 := []byte(c.PostForm("attestation")) + // _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) newAttestation := []byte(c.PostForm("attestation")) c.Request.Body = io.NopCloser(bytes.NewBuffer(newAttestation)) @@ -577,21 +576,21 @@ func UserFinishPasskeyRegistration(c *gin.Context) { //_ = os.WriteFile("/tmp/passkeyerrs", d2, 0644) //d2, err := json.Marshal(wasession) //_ = os.WriteFile("/tmp/passkeyerrs", d2, 0644) - d3, err := httputil.DumpRequest(c.Request, true) - _ = os.WriteFile("/tmp/passkeyerrs", d3, 0644) + // d3, err := httputil.DumpRequest(c.Request, true) + // _ = os.WriteFile("/tmp/passkeyerrs", d3, 0644) - _, err = common.Context().WebAuthn.FinishRegistration(webauthnuser, wasession, c.Request) // webauthn.User and webauthn.SessionData + webauthncredential, err := common.Context().WebAuthn.FinishRegistration(webauthnuser, wasession, c.Request) // webauthn.User and webauthn.SessionData - d1 = []byte(err.Error()) - _ = os.WriteFile("/tmp/passkeyerrs", d1, 0644) + // d1 = []byte(err.Error()) + _ = os.WriteFile("/tmp/passkeyerrs", []byte(webauthncredential.AttestationType), 0644) if AbortIfError(c, err) { return } // GET credential from FinishRegistration above - // user.EncryptedPasskeyCredential = webauthncredential // credential - + // Save Credentials in separate table + user.EncryptedPasskeyCredential = "" // webauthncredential user.EncryptedPasskeySession = "" user.Save() @@ -619,7 +618,7 @@ func UserBeginLoginWithPasskey(c *gin.Context) { expires := session.Expires.String() webauthnsession := challenge + "~" + "" + "~" + userID + "~" + allowedCredentialsIDs + "~" + expires user.EncryptedPasskeySession = webauthnsession - + // _ = os.WriteFile("/tmp/passkeyerrs", []byte(webauthnsession), 0644) user.EncryptedPasskeySession = webauthnsession user.Save() req.TemplateData["sessionKey"] = sessionID // options.sessionKey // header? @@ -634,6 +633,7 @@ func UserFinishLoginWithPasskey(c *gin.Context) { // session := user.EncryptedPasskeySession webauthnuser := &PasskeyUser{ID: []byte(string(user.ID)), DisplayName: user.Name, Name: user.Name} sessionparts := strings.Split(session, "~") + // _ = os.WriteFile("/tmp/passkeyerrs", []byte(session), 0644) bytestr := []byte(sessionparts[3]) allowedCreds := [][]byte{[]byte(bytestr)} layout := "2006-01-02 15:04:05"