From 770ce52ee46d7904f3e9929f671918e93722671a Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Fri, 5 Sep 2025 02:41:52 -0400 Subject: [PATCH 01/10] Added new routes for setting up authenticator apps and validating the TOTP --- app/application.go | 5 ++ constants/constants.go | 1 + constants/permissions.go | 15 ++++++ db/schema.sql | 1 + go.mod | 3 ++ go.sum | 6 +++ middleware/authenticate.go | 2 +- middleware/authorization_map.go | 3 ++ pgmodels/user.go | 6 +++ views/users/setup_authenticator_app.html | 34 +++++++++++++ views/users/validate_totp.html | 38 ++++++++++++++ web/webui/two_factor_controller.go | 65 ++++++++++++++++++++++++ 12 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 views/users/setup_authenticator_app.html create mode 100644 views/users/validate_totp.html diff --git a/app/application.go b/app/application.go index 1c670477..93a5f0d4 100644 --- a/app/application.go +++ b/app/application.go @@ -263,6 +263,11 @@ func initRoutes(router *gin.Engine) { webRoutes.POST("/users/2fa_push", webui.UserTwoFactorPush) webRoutes.POST("/users/2fa_verify", webui.UserTwoFactorVerify) + // Generate and validate Time-based One-Time Passwords for MFA + webRoutes.GET("/users/generate_totp", webui.UserGenerateTOTP) + webRoutes.GET("/users/validate_totp", webui.UserValidateTOTPView) + webRoutes.POST("/users/validate_totp", webui.UserValidateTOTP) + // User forgot password webRoutes.GET("/users/forgot_password", webui.UserShowForgotPasswordForm) webRoutes.POST("/users/forgot_password", webui.UserSendForgotPasswordMessage) diff --git a/constants/constants.go b/constants/constants.go index 3f31cfb3..57b62904 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -132,6 +132,7 @@ const ( TopicFixity = "fixity_check" TopicGlacierRestore = "restore_glacier" TopicObjectRestore = "restore_object" + TOTPSecretIssuer = "APTrust" TwoFactorAuthy = "onetouch" TwoFactorNone = "none" TwoFactorSMS = "sms" diff --git a/constants/permissions.go b/constants/permissions.go index ac97f99c..4876bc65 100644 --- a/constants/permissions.go +++ b/constants/permissions.go @@ -73,6 +73,7 @@ const ( UserDelete = "UserDelete" UserDeleteSelf = "UserDeleteSelf" UserGenerateBackupCodes = "UserGenerateBackupCodes" + UserGenerateTOTP = "UserGenerateTOTP" UserInit2FASetup = "UserInit2FASetup" UserRead = "UserRead" UserReadSelf = "UserReadSelf" @@ -86,6 +87,8 @@ const ( UserTwoFactorVerify = "UserTwoFactorVerify" UserUpdate = "UserUpdate" UserUpdateSelf = "UserUpdateSelf" + UserValidateTOTP = "UserValidateTOTP" + UserValidateTOTPView = "UserValidateTOTPView" WorkItemCreate = "WorkItemCreate" WorkItemDelete = "WorkItemDelete" WorkItemRead = "WorkItemRead" @@ -152,6 +155,7 @@ var Permissions = []Permission{ UserDelete, UserDeleteSelf, UserGenerateBackupCodes, + UserGenerateTOTP, UserInit2FASetup, UserRead, UserReadSelf, @@ -165,6 +169,8 @@ var Permissions = []Permission{ UserTwoFactorVerify, UserUpdate, UserUpdateSelf, + UserValidateTOTP, + UserValidateTOTPView, WorkItemCreate, WorkItemDelete, WorkItemRead, @@ -228,6 +234,7 @@ func initPermissions() { instUser[UserComplete2FASetup] = true instUser[UserConfirmPhone] = true instUser[UserGenerateBackupCodes] = true + instUser[UserGenerateTOTP] = true instUser[UserInit2FASetup] = true instUser[UserReadSelf] = true instUser[UserSignIn] = true @@ -238,6 +245,8 @@ func initPermissions() { instUser[UserTwoFactorPush] = true instUser[UserTwoFactorResend] = true instUser[UserTwoFactorVerify] = true + instUser[UserValidateTOTP] = true + instUser[UserValidateTOTPView] = true instUser[UserUpdateSelf] = true instUser[WorkItemRead] = true @@ -268,6 +277,7 @@ func initPermissions() { instAdmin[UserCreate] = true instAdmin[UserDelete] = true instAdmin[UserGenerateBackupCodes] = true + instAdmin[UserGenerateTOTP] = true instAdmin[UserInit2FASetup] = true instAdmin[UserReadSelf] = true instAdmin[UserRead] = true @@ -279,6 +289,8 @@ func initPermissions() { instAdmin[UserTwoFactorPush] = true instAdmin[UserTwoFactorResend] = true instAdmin[UserTwoFactorVerify] = true + instAdmin[UserValidateTOTP] = true + instAdmin[UserValidateTOTPView] = true instAdmin[UserUpdateSelf] = true instAdmin[UserUpdate] = true instAdmin[WorkItemRead] = true @@ -341,6 +353,7 @@ func initPermissions() { sysAdmin[UserDeleteSelf] = true sysAdmin[UserDelete] = true sysAdmin[UserGenerateBackupCodes] = true + sysAdmin[UserGenerateTOTP] = true sysAdmin[UserInit2FASetup] = true sysAdmin[UserReadSelf] = true sysAdmin[UserRead] = true @@ -352,6 +365,8 @@ func initPermissions() { sysAdmin[UserTwoFactorPush] = true sysAdmin[UserTwoFactorResend] = true sysAdmin[UserTwoFactorVerify] = true + sysAdmin[UserValidateTOTP] = true + sysAdmin[UserValidateTOTPView] = true sysAdmin[UserUpdateSelf] = true sysAdmin[UserUpdate] = true sysAdmin[WorkItemCreate] = true diff --git a/db/schema.sql b/db/schema.sql index 0d324f5b..bbc4226d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -485,6 +485,7 @@ CREATE TABLE public.users ( encrypted_otp_secret_iv varchar NULL, encrypted_otp_secret_salt varchar NULL, encrypted_otp_sent_at timestamp NULL, + -- encrypted_totp_app_secret varchar NULL, consumed_timestep int4 NULL, otp_required_for_login bool NULL, deactivated_at timestamp NULL, diff --git a/go.mod b/go.mod index eb0fcd7f..686f3574 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/andybalholm/brotli v1.0.4 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -72,8 +73,10 @@ require ( github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pkg/errors v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/sanity-io/litter v1.5.5 // indirect github.com/sergi/go-diff v1.0.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/afero v1.1.2 // indirect github.com/spf13/cast v1.3.0 // indirect github.com/spf13/jwalterweatherman v1.0.0 // indirect diff --git a/go.sum b/go.sum index 76495f6b..38d7eed4 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b h1:AP/Y7sqYicnjGDfD5VcY4CIfh1hRXBUavxrvELjTiOE= github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/brianvoe/gofakeit/v6 v6.9.0 h1:UCGhPCKLiqBc910TKS7LcOGf74NozftibFCbGIS6GZQ= github.com/brianvoe/gofakeit/v6 v6.9.0/go.mod h1:palrJUk4Fyw38zIFB/uBZqsgzW5VsNllhHKKwAebzew= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= @@ -273,6 +275,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= @@ -297,6 +301,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= diff --git a/middleware/authenticate.go b/middleware/authenticate.go index 1b665163..d8164d24 100644 --- a/middleware/authenticate.go +++ b/middleware/authenticate.go @@ -174,7 +174,7 @@ func SetDefaultHeaders(c *gin.Context) { // Set these security headers on all responses. c.Writer.Header().Set("X-XSS-Protection", "1") c.Writer.Header().Set("X-Content-Type-Options", "nosniff") - c.Writer.Header().Set("Content-Security-Policy", "default-src 'self'; font-src 'self' fonts.gstatic.com; style-src 'self' 'unsafe-inline' fonts.googleapis.com; script-src 'self' 'unsafe-inline'") + c.Writer.Header().Set("Content-Security-Policy", "default-src 'self'; font-src 'self' fonts.gstatic.com; style-src 'self' 'unsafe-inline' fonts.googleapis.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:;") } func forceCompletionOfPasswordChange(c *gin.Context, currentUser *pgmodels.User) bool { diff --git a/middleware/authorization_map.go b/middleware/authorization_map.go index 77c4b867..799d4d2c 100644 --- a/middleware/authorization_map.go +++ b/middleware/authorization_map.go @@ -122,6 +122,7 @@ var AuthMap = map[string]AuthMetadata{ "UserDeleteSelf": {"User", constants.UserDeleteSelf, "Deactivate Your Account"}, "UserEdit": {"User", constants.UserUpdate, "Edit User"}, "UserGenerateBackupCodes": {"User", constants.UserGenerateBackupCodes, "Create Two-Factor Backup Codes"}, + "UserGenerateTOTP": {"User", constants.UserGenerateTOTP, "Authenticator App Setup"}, "UserGetAPIKey": {"User", constants.UserUpdateSelf, "Generate API Key"}, "UserIndex": {"User", constants.UserRead, "Users"}, "UserInit2FASetup": {"User", constants.UserInit2FASetup, "Start Two-Factor Setup"}, @@ -141,6 +142,8 @@ var AuthMap = map[string]AuthMetadata{ "UserUpdate": {"User", constants.UserUpdate, "Update User"}, "UserUpdateXHR": {"User", constants.UserUpdate, "Update User"}, "UserUpdateSelf": {"User", constants.UserUpdateSelf, "Update User"}, + "UserValidateTOTP": {"User", constants.UserValidateTOTP, "MFA Confirm TOTP Via Authenticator App"}, + "UserValidateTOTPView": {"User", constants.UserValidateTOTPView, "MFA Confirm TOTP Via Authenticator App Form"}, "WorkItemCreate": {"WorkItem", constants.WorkItemCreate, "Create Work Item"}, "WorkItemDelete": {"WorkItem", constants.WorkItemDelete, "Delete Work Item"}, "WorkItemEdit": {"WorkItem", constants.WorkItemUpdate, "Edit Work Item"}, diff --git a/pgmodels/user.go b/pgmodels/user.go index 0917ced3..421ad543 100644 --- a/pgmodels/user.go +++ b/pgmodels/user.go @@ -112,6 +112,12 @@ type User struct { // we're waiting for a user to enter a text/SMS OTP. EncryptedOTPSentAt time.Time `json:"-" form:"-" pg:"encrypted_otp_sent_at"` + // EncryptedTOTPAppSecret is a secret value shared with the user's device. + // This value is not used for SMS OTP - that would be the EncryptedOTPSecret. + // Rather, this value is used with authenticator apps if the user has + // Authenticator App MFA. + // EncryptedTOTPAppSecret string `json:"-" form:"-" pg:"encrypted_totp_app_secret"` + // ConsumedTimestep is a legacy field from Devise, which used it // for time-based one-time passwords. Not used. // TODO: Delete this. diff --git a/views/users/setup_authenticator_app.html b/views/users/setup_authenticator_app.html new file mode 100644 index 00000000..ee4d3ea8 --- /dev/null +++ b/views/users/setup_authenticator_app.html @@ -0,0 +1,34 @@ +{{ define "users/setup_authenticator_app.html" }} + + +{{ if not .showAsModal }} + {{ template "shared/_header.html" .}} +{{ end }} + + + + +{{ if not .showAsModal }} + {{ template "shared/_footer.html" .}} +{{ end }} + + +{{ end }} diff --git a/views/users/validate_totp.html b/views/users/validate_totp.html new file mode 100644 index 00000000..91ae954b --- /dev/null +++ b/views/users/validate_totp.html @@ -0,0 +1,38 @@ +{{ define "users/validate_totp.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..8dd89050 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -1,6 +1,7 @@ package webui import ( + "encoding/base64" "fmt" "net/http" "time" @@ -10,6 +11,8 @@ import ( "github.com/APTrust/registry/forms" "github.com/APTrust/registry/helpers" "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" + "github.com/skip2/go-qrcode" "github.com/stretchr/stew/slice" ) @@ -197,6 +200,8 @@ func UserComplete2FASetup(c *gin.Context) { return } + // TO DO - If chose TOTP with an Authenticator app, and not set up with it, display /users/generate_totp to setup. + if prefs.UseAuthy() { ok, err := userCompleteAuthySetup(req, prefs) if AbortIfError(c, err) { @@ -226,6 +231,7 @@ func UserComplete2FASetup(c *gin.Context) { helpers.SetFlashCookie(c, "Your phone number has been updated.") } } + c.Redirect(http.StatusFound, "/users/my_account") } @@ -397,3 +403,62 @@ func OTPTokenIsExpired(tokenSentAt time.Time) bool { expiration := tokenSentAt.Add(common.Context().Config.TwoFactor.OTPExpiration) return time.Now().After(expiration) } + +// Generates a QR code that a user can use to set up MFA with any Authenticator App +// on their device. +// +// GET /users/generate_totp +func UserGenerateTOTP(c *gin.Context) { + req := NewRequest(c) + user := req.CurrentUser + // if user.EncryptedTOTPAppSecret == "" { + if 1 == 1 { + secret, err := totp.Generate(totp.GenerateOpts{ + Issuer: constants.TOTPSecretIssuer, + AccountName: user.Email, + }) + if err != nil { + if AbortIfError(c, err) { + return + } + } + // user.EncryptedTOTPAppSecret = secret.Secret() + // err = user.Save() + // if AbortIfError(c, err) { + // return + // } + req.TemplateData["sec"] = secret // temp - remove + } + // otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, user.EncryptedTOTPAppSecret, constants.TOTPSecretIssuer) + otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, "nonesuch", constants.TOTPSecretIssuer) + png, err := qrcode.Encode(otpURL, qrcode.Medium, 256) + if err != nil { + if AbortIfError(c, err) { + return + } + } + otpQRImage := base64.StdEncoding.EncodeToString(png) + req.TemplateData["otpQRImage"] = otpQRImage + c.HTML(http.StatusOK, "users/setup_authenticator_app.html", req.TemplateData) +} + +// Displays page for user to validate their TOTP using an authenticator app. +func UserValidateTOTPView(c *gin.Context) { + req := NewRequest(c) + c.HTML(http.StatusOK, "users/validate_totp.html", req.TemplateData) +} + +// Validates a TOTP provided by the user from their authenticator app. +func UserValidateTOTP(c *gin.Context) { + req := NewRequest(c) + // user := req.CurrentUser + totpCode := c.PostForm("totpCode") + // isValid := totp.Validate(totpCode, user.EncryptedTOTPAppSecret) + isValid := totp.Validate(totpCode, "nonesuch") + if !isValid { + req.TemplateData["errorMessage"] = "Oops! That wasn't the right code. Please try again." + c.HTML(http.StatusOK, "users/validate_totp.html", req.TemplateData) + return + } + c.Redirect(http.StatusFound, "/dashboard") +} From 9c9d06508da730bdc1b4a991262065aa6bff0d30 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Mon, 8 Sep 2025 16:57:30 -0400 Subject: [PATCH 02/10] Add the option to switch to an authenticator app and add to docs --- constants/constants.go | 2 ++ forms/lists.go | 1 + forms/two_factor_setup_form.go | 14 ++++++++++++-- notes.md | 5 +++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/constants/constants.go b/constants/constants.go index 57b62904..5be1f6f8 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -84,6 +84,7 @@ const ( RoleInstUser = "institutional_user" RoleNone = "none" RoleSysAdmin = "admin" + SecondFactorTOTP = "Authenticator App" SecondFactorAuthy = "Authy" SecondFactorBackupCode = "Backup Code" SecondFactorSMS = "SMS" @@ -133,6 +134,7 @@ const ( TopicGlacierRestore = "restore_glacier" TopicObjectRestore = "restore_object" TOTPSecretIssuer = "APTrust" + TwoFactorTOTP = "totp" TwoFactorAuthy = "onetouch" TwoFactorNone = "none" TwoFactorSMS = "sms" diff --git a/forms/lists.go b/forms/lists.go index 428fe180..bf589bf2 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.TwoFactorTOTP, "Authenticator App (QR Code)", 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..c68236a3 100644 --- a/forms/two_factor_setup_form.go +++ b/forms/two_factor_setup_form.go @@ -18,8 +18,8 @@ func NewTwoFactorSetupForm(user *pgmodels.User) *TwoFactorSetupForm { } func (f *TwoFactorSetupForm) init() { - f.Fields["AuthyStatus"] = &Field{ - Name: "AuthyStatus", + f.Fields["TOTP"] = &Field{ + Name: "TOTP", Label: "Preferred Method for Two-Factor Auth", Placeholder: "", ErrMsg: "Please choose your preferred method.", @@ -28,6 +28,16 @@ func (f *TwoFactorSetupForm) init() { "required": "", }, } + f.Fields["AuthyStatus"] = &Field{ + Name: "AuthyStatus", + Label: "Authy", + Placeholder: "Authy", + ErrMsg: "Please choose your preferred method.", + Options: TwoFactorMethodList, + Attrs: map[string]string{ + "required": "", + }, + } f.Fields["PhoneNumber"] = &Field{ Name: "PhoneNumber", Label: "PhoneNumber", diff --git a/notes.md b/notes.md index 20d34ab4..885ae219 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 authenticator app (recommended) * 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. +You can utilize any authenticator app that supports QR codes for two-factor authentication, including but not limited to Google Authenticator, Microsoft Authenticator, or Duo. + 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 +The recommended method currently is to use an authenticator app on your device. You can use any authenticator app that supports QR codes, such as Google Authenticator, Microsoft Authenticator, or Duo. + 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). From b3dce92b7e069a3fd53a05d9c171765f6976ffe7 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Wed, 17 Sep 2025 03:10:07 -0400 Subject: [PATCH 03/10] Integrating the authenticator app option as enrollable and able to be verified within Registry. --- db/schema.sql | 2 +- forms/lists.go | 2 +- forms/two_factor_setup_form.go | 12 +---- pgmodels/user.go | 17 +++++-- views/users/choose_second_factor.html | 21 +++++++- views/users/setup_authenticator_app.html | 11 ++++ views/users/validate_totp.html | 3 +- web/webui/two_factor_controller.go | 64 +++++++++++++++++++++--- web/webui/two_factor_preferences.go | 46 ++++++++++++----- web/webui/two_factor_preferences_test.go | 17 +++++++ web/webui/users_controller.go | 4 +- 11 files changed, 159 insertions(+), 40 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index bbc4226d..7589b981 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -485,7 +485,7 @@ CREATE TABLE public.users ( encrypted_otp_secret_iv varchar NULL, encrypted_otp_secret_salt varchar NULL, encrypted_otp_sent_at timestamp NULL, - -- encrypted_totp_app_secret varchar NULL, + -- encrypted_auth_app_secret varchar NULL, consumed_timestep int4 NULL, otp_required_for_login bool NULL, deactivated_at timestamp NULL, diff --git a/forms/lists.go b/forms/lists.go index bf589bf2..a40959ff 100644 --- a/forms/lists.go +++ b/forms/lists.go @@ -80,7 +80,7 @@ var StorageOptionList = []*ListOption{ var TwoFactorMethodList = []*ListOption{ {constants.TwoFactorNone, "None (Turn Off Two-Factor Authentication)", false}, - {constants.TwoFactorTOTP, "Authenticator App (QR Code)", false}, + {constants.TwoFactorTOTP, "Authenticator App", 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 c68236a3..3d60b519 100644 --- a/forms/two_factor_setup_form.go +++ b/forms/two_factor_setup_form.go @@ -18,19 +18,9 @@ func NewTwoFactorSetupForm(user *pgmodels.User) *TwoFactorSetupForm { } func (f *TwoFactorSetupForm) init() { - f.Fields["TOTP"] = &Field{ - Name: "TOTP", - Label: "Preferred Method for Two-Factor Auth", - Placeholder: "", - ErrMsg: "Please choose your preferred method.", - Options: TwoFactorMethodList, - Attrs: map[string]string{ - "required": "", - }, - } f.Fields["AuthyStatus"] = &Field{ Name: "AuthyStatus", - Label: "Authy", + Label: "Preferred Method for Two-Factor Auth", Placeholder: "Authy", ErrMsg: "Please choose your preferred method.", Options: TwoFactorMethodList, diff --git a/pgmodels/user.go b/pgmodels/user.go index 421ad543..286eabaa 100644 --- a/pgmodels/user.go +++ b/pgmodels/user.go @@ -112,11 +112,11 @@ type User struct { // we're waiting for a user to enter a text/SMS OTP. EncryptedOTPSentAt time.Time `json:"-" form:"-" pg:"encrypted_otp_sent_at"` - // EncryptedTOTPAppSecret is a secret value shared with the user's device. + // EncryptedAuthAppSecret is a secret value shared with the user's device. // This value is not used for SMS OTP - that would be the EncryptedOTPSecret. // Rather, this value is used with authenticator apps if the user has - // Authenticator App MFA. - // EncryptedTOTPAppSecret string `json:"-" form:"-" pg:"encrypted_totp_app_secret"` + // Authenticator App MFA enabled. + // EncryptedAuthAppSecret string `json:"-" form:"-" pg:"encrypted_auth_app_secret"` // ConsumedTimestep is a legacy field from Devise, which used it // for time-based one-time passwords. Not used. @@ -383,6 +383,12 @@ func (user *User) IsAuthyOneTouchUser() bool { return user.IsTwoFactorUser() && (user.AuthyStatus == constants.TwoFactorAuthy) } +// IsAuthenticatorAppUser returns true if the user has enabled an authenticator app +// for two-factor login. +func (user *User) IsAuthenticatorAppUser() bool { + return user.IsTwoFactorUser() && (user.AuthyStatus == constants.TwoFactorTOTP) +} + // IsTwoFactorUser returns true if this user has enabled and confirmed // two factor authentication. // @@ -407,7 +413,10 @@ func (user *User) TwoFactorMethod() string { if user.IsSMSUser() { return constants.TwoFactorSMS } - return constants.TwoFactorAuthy + if user.IsAuthyOneTouchUser() { + return constants.TwoFactorAuthy + } + return constants.TwoFactorTOTP } // 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..80d71391 100644 --- a/views/users/choose_second_factor.html +++ b/views/users/choose_second_factor.html @@ -25,6 +25,22 @@

Multi-Factor Authentication Required

+ {{ if not .showAsModal }} {{ template "shared/_footer.html" .}} diff --git a/views/users/validate_totp.html b/views/users/validate_totp.html index 91ae954b..32917a17 100644 --- a/views/users/validate_totp.html +++ b/views/users/validate_totp.html @@ -22,7 +22,8 @@

Enter One-Time Code From Your Authenticator App

{{ end }} - + + {{ template "forms/csrf_token.html" . }} diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index 8dd89050..fa474703 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -102,7 +102,11 @@ func UserTwoFactorVerify(c *gin.Context) { user := req.CurrentUser - if method == constants.TwoFactorSMS { + if method == constants.TwoFactorTOTP { + if OTPTokenIsExpired(user.EncryptedOTPSentAt) { + c.Redirect(http.StatusFound, "/users/validate_totp") + } + } else if method == constants.TwoFactorSMS { if OTPTokenIsExpired(user.EncryptedOTPSentAt) { helpers.SetFlashCookie(c, "Your one-time password expired. Please sign in again.") c.Redirect(http.StatusFound, "/users/sign_out") @@ -120,7 +124,9 @@ func UserTwoFactorVerify(c *gin.Context) { // User model needs FailedLogins and LockoutUntil. // Then we need logic to enforce the lockout. msg := "Backup code is incorrect. Try again." - if method == constants.TwoFactorSMS { + if method == constants.TwoFactorTOTP { + msg = "One-time code is incorrect. Try again." + } else if method == constants.TwoFactorSMS { msg = "One-time password is incorrect. Try again." } req.TemplateData["flash"] = msg @@ -200,7 +206,22 @@ func UserComplete2FASetup(c *gin.Context) { return } - // TO DO - If chose TOTP with an Authenticator app, and not set up with it, display /users/generate_totp to setup. + if prefs.UseAuthenticatorApp() { + userCompleteAuthenticatorAppSetup(c, req, prefs) + return + /* if AbortIfError(c, err) { + return + } + if ok { + helpers.SetFlashCookie(c, "Your two-factor setup is complete. Next time you log in, you will need to use your authenticator app and provide a six-digit one-time code to complete the sign-in process.") + c.Redirect(http.StatusFound, "/users/my_account") + return + } else { + // User did not approve + c.Redirect(http.StatusFound, "/users/sign_out") + return + } */ + } if prefs.UseAuthy() { ok, err := userCompleteAuthySetup(req, prefs) @@ -381,6 +402,17 @@ func userCompleteAuthySetup(req *Request, prefs *TwoFactorPreferences) (ok bool, return ok, err } +func userCompleteAuthenticatorAppSetup(c *gin.Context, req *Request, prefs *TwoFactorPreferences) { + if prefs.NeedsAuthenticatorAppRegistration() { + c.Redirect(http.StatusFound, "/users/generate_totp") + return + } else if prefs.NeedsAuthenticatorAppConfirmation() { + c.Redirect(http.StatusFound, "/users/validate_totp") + return + } + c.Redirect(http.StatusFound, "/users/my_account") +} + func UserCompleteSMSSetup(req *Request) error { // Send SMS code and redirect to UserConfirmPhone user := req.CurrentUser @@ -411,7 +443,7 @@ func OTPTokenIsExpired(tokenSentAt time.Time) bool { func UserGenerateTOTP(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser - // if user.EncryptedTOTPAppSecret == "" { + // if user.EncryptedAuthAppSecret == "" { if 1 == 1 { secret, err := totp.Generate(totp.GenerateOpts{ Issuer: constants.TOTPSecretIssuer, @@ -422,14 +454,14 @@ func UserGenerateTOTP(c *gin.Context) { return } } - // user.EncryptedTOTPAppSecret = secret.Secret() + // user.EncryptedAuthAppSecret = secret.Secret() // err = user.Save() // if AbortIfError(c, err) { // return // } req.TemplateData["sec"] = secret // temp - remove } - // otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, user.EncryptedTOTPAppSecret, constants.TOTPSecretIssuer) + // otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, user.EncryptedAuthAppSecret, constants.TOTPSecretIssuer) otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, "nonesuch", constants.TOTPSecretIssuer) png, err := qrcode.Encode(otpURL, qrcode.Medium, 256) if err != nil { @@ -439,6 +471,8 @@ func UserGenerateTOTP(c *gin.Context) { } otpQRImage := base64.StdEncoding.EncodeToString(png) req.TemplateData["otpQRImage"] = otpQRImage + // req.TemplateData["secVal"] = user.EncryptedAuthAppSecret + req.TemplateData["secVal"] = "nonesuch" c.HTML(http.StatusOK, "users/setup_authenticator_app.html", req.TemplateData) } @@ -453,12 +487,26 @@ func UserValidateTOTP(c *gin.Context) { req := NewRequest(c) // user := req.CurrentUser totpCode := c.PostForm("totpCode") - // isValid := totp.Validate(totpCode, user.EncryptedTOTPAppSecret) + confirming := c.PostForm("firstConfirm") + // isValid := totp.Validate(totpCode, user.EncryptedAuthAppSecret) isValid := totp.Validate(totpCode, "nonesuch") if !isValid { req.TemplateData["errorMessage"] = "Oops! That wasn't the right code. Please try again." c.HTML(http.StatusOK, "users/validate_totp.html", req.TemplateData) return + } else { + if confirming == "" { + req.CurrentUser.AwaitingSecondFactor = false + + err := req.CurrentUser.Save() + if err != nil { + if AbortIfError(c, err) { + return + } + } + } + helpers.SetFlashCookie(c, "Logged in successfully!") + c.Redirect(http.StatusFound, "/dashboard") + return } - c.Redirect(http.StatusFound, "/dashboard") } diff --git a/web/webui/two_factor_preferences.go b/web/webui/two_factor_preferences.go index 961975fb..6c59fa07 100644 --- a/web/webui/two_factor_preferences.go +++ b/web/webui/two_factor_preferences.go @@ -6,16 +6,19 @@ import ( ) type TwoFactorPreferences struct { - OldPhone string - NewPhone string - OldMethod string - NewMethod string - User *pgmodels.User + OldPhone string + NewPhone string + OldMethod string + NewMethod string + OldAuthAppMethod string + NewAuthAppMethod string + User *pgmodels.User } func NewTwoFactorPreferences(req *Request) (*TwoFactorPreferences, error) { oldPhone := req.CurrentUser.PhoneNumber oldMethod := req.CurrentUser.AuthyStatus + oldAuthAppMethod := constants.TwoFactorNone // req.CurrentUser.UseAuthenticatorApp // Get phone and authy data submitted in the form. user := &pgmodels.User{} @@ -29,11 +32,13 @@ func NewTwoFactorPreferences(req *Request) (*TwoFactorPreferences, error) { user.ReformatPhone() prefs := &TwoFactorPreferences{ - OldPhone: oldPhone, - NewPhone: user.PhoneNumber, - OldMethod: oldMethod, - NewMethod: user.AuthyStatus, - User: user, + OldPhone: oldPhone, + NewPhone: user.PhoneNumber, + OldMethod: oldMethod, + NewMethod: user.AuthyStatus, + User: user, + OldAuthAppMethod: oldAuthAppMethod, + NewAuthAppMethod: constants.TwoFactorNone, } return prefs, nil @@ -47,18 +52,26 @@ func (p *TwoFactorPreferences) MethodChanged() bool { return p.OldMethod != p.NewMethod } +func (p *TwoFactorPreferences) AuthAppMethodChanged() bool { + return p.OldAuthAppMethod != p.NewAuthAppMethod +} + func (p *TwoFactorPreferences) NeedsConfirmation() bool { - return p.PhoneChanged() || p.MethodChanged() + return p.PhoneChanged() || p.MethodChanged() || p.AuthAppMethodChanged() } func (p *TwoFactorPreferences) NothingChanged() bool { - return !p.PhoneChanged() && !p.MethodChanged() + return !p.PhoneChanged() && !p.MethodChanged() && !p.AuthAppMethodChanged() } func (p *TwoFactorPreferences) DoNotUseTwoFactor() bool { return p.NewMethod == constants.TwoFactorNone } +func (p *TwoFactorPreferences) UseAuthenticatorApp() bool { + return p.NewMethod == constants.TwoFactorTOTP +} + func (p *TwoFactorPreferences) UseAuthy() bool { return p.NewMethod == constants.TwoFactorAuthy } @@ -75,6 +88,15 @@ func (p *TwoFactorPreferences) NeedsAuthyConfirmation() bool { return p.NeedsConfirmation() && p.NewMethod == constants.TwoFactorAuthy } +func (p *TwoFactorPreferences) NeedsAuthenticatorAppRegistration() bool { + return true + // return p.NewAuthAppMethod == constants.TwoFactorTOTP && p.User.TOTPSecret == "" +} + +func (p *TwoFactorPreferences) NeedsAuthenticatorAppConfirmation() bool { + return p.NeedsConfirmation() && p.NewMethod == constants.TwoFactorTOTP +} + func (p *TwoFactorPreferences) NeedsSMSConfirmation() bool { return p.NeedsConfirmation() && p.NewMethod == constants.TwoFactorSMS } diff --git a/web/webui/two_factor_preferences_test.go b/web/webui/two_factor_preferences_test.go index 25f932ca..9529c4a9 100644 --- a/web/webui/two_factor_preferences_test.go +++ b/web/webui/two_factor_preferences_test.go @@ -30,16 +30,25 @@ func TestTwoFactorPreferences(t *testing.T) { assert.True(t, prefs.DoNotUseTwoFactor()) assert.False(t, prefs.UseAuthy()) assert.False(t, prefs.UseSMS()) + assert.False(t, prefs.UseAuthenticatorApp()) prefs.NewMethod = constants.TwoFactorSMS assert.False(t, prefs.DoNotUseTwoFactor()) assert.False(t, prefs.UseAuthy()) assert.True(t, prefs.UseSMS()) + assert.False(t, prefs.UseAuthenticatorApp()) prefs.NewMethod = constants.TwoFactorAuthy assert.False(t, prefs.DoNotUseTwoFactor()) assert.True(t, prefs.UseAuthy()) assert.False(t, prefs.UseSMS()) + assert.False(t, prefs.UseAuthenticatorApp()) + + prefs.NewMethod = constants.TwoFactorTOTP + assert.False(t, prefs.DoNotUseTwoFactor()) + assert.False(t, prefs.UseAuthy()) + assert.False(t, prefs.UseSMS()) + assert.True(t, prefs.UseAuthenticatorApp()) prefs.NewMethod = constants.TwoFactorAuthy prefs.User.AuthyID = "" @@ -49,6 +58,14 @@ func TestTwoFactorPreferences(t *testing.T) { prefs.User.AuthyID = "" assert.False(t, prefs.NeedsAuthyRegistration()) + prefs.NewMethod = constants.TwoFactorAuthy + prefs.User.AuthyID = "" + assert.True(t, prefs.NeedsAuthyRegistration()) + + prefs.NewMethod = constants.TwoFactorTOTP + prefs.User.AuthyID = "" + assert.False(t, prefs.NeedsAuthyRegistration()) + prefs.NewMethod = constants.TwoFactorAuthy prefs.User.AuthyID = "12345" assert.False(t, prefs.NeedsAuthyRegistration()) diff --git a/web/webui/users_controller.go b/web/webui/users_controller.go index cb022bbd..f0323fa1 100644 --- a/web/webui/users_controller.go +++ b/web/webui/users_controller.go @@ -524,7 +524,9 @@ func SignInUser(c *gin.Context) (int, string, error) { c.Set("CurrentUser", user) redirectTo = "/dashboard" - if user.IsTwoFactorUser() { + if user.IsTwoFactorUser() && user.IsAuthenticatorAppUser() { + redirectTo = "/users/validate_totp" + } else if user.IsTwoFactorUser() { redirectTo = "/users/2fa_choose" } return http.StatusFound, redirectTo, nil From 59c3671bafa9c9cb160607cc418ea9198b7dda22 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Wed, 17 Sep 2025 15:43:12 -0400 Subject: [PATCH 04/10] Applied DB persistence to auth app secret --- db/fixtures/users.csv | 20 ++++++------ db/migrations/013_add_auth_app_secret.sql | 12 +++++++ db/schema.sql | 2 +- pgmodels/user.go | 2 +- views/users/choose_second_factor.html | 10 +++--- web/webui/two_factor_controller.go | 39 +++++++++++++---------- 6 files changed, 52 insertions(+), 33 deletions(-) create mode 100644 db/migrations/013_add_auth_app_secret.sql diff --git a/db/fixtures/users.csv b/db/fixtures/users.csv index 78247fdb..ebbad5ab 100644 --- a/db/fixtures/users.csv +++ b/db/fixtures/users.csv @@ -1,10 +1,10 @@ -id,name,email,phone_number,created_at,updated_at,encrypted_password,reset_password_token,reset_password_sent_at,remember_created_at,sign_in_count,current_sign_in_at,last_sign_in_at,current_sign_in_ip,last_sign_in_ip,institution_id,encrypted_api_secret_key,password_changed_at,encrypted_otp_secret,encrypted_otp_secret_iv,encrypted_otp_secret_salt,encrypted_otp_sent_at,consumed_timestep,otp_required_for_login,deactivated_at,enabled_two_factor,confirmed_two_factor,otp_backup_codes,authy_id,last_sign_in_with_authy,authy_status,email_verified,initial_password_updated,force_password_update,account_confirmed,grace_period,awaiting_second_factor,role -4,Inactive User,inactive@inst1.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,2,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,1/15/21 13:49,FALSE,FALSE,"{code1,code2,code3}",,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,none -5,Inst Two Admin,admin@inst2.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,3,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_admin -7,Inst Two User,user@inst2.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,3,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_user -2,Inst One Admin,admin@inst1.edu,14345551212,1/12/21 17:14,9/10/21 14:22,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,2,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,,,,,,,TRUE,TRUE,,TRUE,12/31/99 23:59,FALSE,institutional_admin -3,Inst One User,user@inst1.edu,14345551212,1/12/21 17:14,9/10/21 14:22,$2a$10$raEJqJ7eRcEwWmeoiJ2vxenR8dqVXCI1SU9zcgkrxeS.6/haWGi4K,,,,1,9/10/21 14:22,,,,2,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,,,,,,,TRUE,TRUE,,TRUE,12/31/99 23:59,FALSE,institutional_user -1,APTrust System,system@aptrust.org,14345551212,1/12/21 17:14,9/10/21 14:24,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,1,9/10/21 14:24,,127.0.0.1,,1,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,,,,,,,TRUE,TRUE,,TRUE,12/31/99 23:59,FALSE,admin -6,Two Factor SMS User,sms_user@example.com,12125551212,9/10/21 14:25,9/10/21 14:25,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,2,,,,,,,,TRUE,,,,,,,,,,TRUE,TRUE,11/9/21 5:00,FALSE,institutional_user -8,Test.edu Admin,admin@test.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,4,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_admin -9,Test.edu User,user@test.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,4,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_user \ No newline at end of file +id,name,email,phone_number,created_at,updated_at,encrypted_password,reset_password_token,reset_password_sent_at,remember_created_at,sign_in_count,current_sign_in_at,last_sign_in_at,current_sign_in_ip,last_sign_in_ip,institution_id,encrypted_api_secret_key,password_changed_at,encrypted_otp_secret,encrypted_otp_secret_iv,encrypted_otp_secret_salt,encrypted_otp_sent_at,consumed_timestep,otp_required_for_login,deactivated_at,enabled_two_factor,confirmed_two_factor,otp_backup_codes,authy_id,last_sign_in_with_authy,authy_status,email_verified,initial_password_updated,force_password_update,account_confirmed,grace_period,awaiting_second_factor,role,encrypted_auth_app_secret +4,Inactive User,inactive@inst1.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,2,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,1/15/21 13:49,FALSE,FALSE,"{code1,code2,code3}",,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,none, +5,Inst Two Admin,admin@inst2.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,3,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_admin, +7,Inst Two User,user@inst2.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,3,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_user, +2,Inst One Admin,admin@inst1.edu,14345551212,1/12/21 17:14,9/10/21 14:22,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,2,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,,,,,,,TRUE,TRUE,,TRUE,12/31/99 23:59,FALSE,institutional_admin, +3,Inst One User,user@inst1.edu,14345551212,1/12/21 17:14,9/10/21 14:22,$2a$10$raEJqJ7eRcEwWmeoiJ2vxenR8dqVXCI1SU9zcgkrxeS.6/haWGi4K,,,,1,9/10/21 14:22,,,,2,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,,,,,,,TRUE,TRUE,,TRUE,12/31/99 23:59,FALSE,institutional_user, +1,APTrust System,system@aptrust.org,14345551212,1/12/21 17:14,9/10/21 14:24,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,1,9/10/21 14:24,,127.0.0.1,,1,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,,,,,,,TRUE,TRUE,,TRUE,12/31/99 23:59,FALSE,admin, +6,Two Factor SMS User,sms_user@example.com,12125551212,9/10/21 14:25,9/10/21 14:25,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,2,,,,,,,,TRUE,,,,,,,,,,TRUE,TRUE,11/9/21 5:00,FALSE,institutional_user, +8,Test.edu Admin,admin@test.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,4,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_admin, +9,Test.edu User,user@test.edu,14345551212,1/12/21 17:14,1/12/21 17:14,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,0,,,,,4,$2a$10$7aoot2KFFqikpTYVEbErYOxZijCHDPvqT4OMoFwdmsYBE9SK2PibC,,,,,,,,,FALSE,FALSE,,,,,TRUE,TRUE,FALSE,TRUE,12/31/99 23:59,FALSE,institutional_user, \ No newline at end of file diff --git a/db/migrations/013_add_auth_app_secret.sql b/db/migrations/013_add_auth_app_secret.sql new file mode 100644 index 00000000..b4da8cef --- /dev/null +++ b/db/migrations/013_add_auth_app_secret.sql @@ -0,0 +1,12 @@ +-- 013_add_auth_app_secret.sql +-- Adds a field to represent a secret from an authenticator app for use with MFA + +-- Note that we're starting the migration. +insert into schema_migrations ("version", started_at) values ('013_add_auth_app_secret', now()) +on conflict ("version") do update set started_at = now(); + +-- Add new encrypted_auth_app_secret to the users table +alter table users add column if not exists encrypted_auth_app_secret varchar null; + +-- Now mark the migration as completed. +update schema_migrations set finished_at = now() where "version" = '013_add_auth_app_secret'; diff --git a/db/schema.sql b/db/schema.sql index 7589b981..e3395439 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -485,7 +485,6 @@ CREATE TABLE public.users ( encrypted_otp_secret_iv varchar NULL, encrypted_otp_secret_salt varchar NULL, encrypted_otp_sent_at timestamp NULL, - -- encrypted_auth_app_secret varchar NULL, consumed_timestep int4 NULL, otp_required_for_login bool NULL, deactivated_at timestamp NULL, @@ -502,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_auth_app_secret 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/pgmodels/user.go b/pgmodels/user.go index 286eabaa..90eddd6c 100644 --- a/pgmodels/user.go +++ b/pgmodels/user.go @@ -116,7 +116,7 @@ type User struct { // This value is not used for SMS OTP - that would be the EncryptedOTPSecret. // Rather, this value is used with authenticator apps if the user has // Authenticator App MFA enabled. - // EncryptedAuthAppSecret string `json:"-" form:"-" pg:"encrypted_auth_app_secret"` + EncryptedAuthAppSecret string `json:"-" form:"-" pg:"encrypted_auth_app_secret"` // ConsumedTimestep is a legacy field from Devise, which used it // for time-based one-time passwords. Not used. diff --git a/views/users/choose_second_factor.html b/views/users/choose_second_factor.html index 80d71391..e2e9157a 100644 --- a/views/users/choose_second_factor.html +++ b/views/users/choose_second_factor.html @@ -34,7 +34,7 @@

Multi-Factor Authentication Required

{{ .errorMessage }}

{{ end }} -
+ {{ template "forms/csrf_token.html" . }} @@ -74,8 +74,8 @@

Multi-Factor Authentication Required

form["two_factor_method"].value = twoFactorMethod if (twoFactorMethod == "totp") { - form = document.forms["validateTOTP"] - addCsrf(form, csrfToken) + // form = document.forms["validateTOTP"] + // addCsrf(form, csrfToken) } else if (twoFactorMethod == "backup") { form["csrf_token"] = null form.method = "get" @@ -90,7 +90,9 @@

Multi-Factor Authentication Required

form.action = "/users/2fa_sms/" } showTwoFactorMessage(twoFactorMethod) - form.submit() + if (twoFactorMethod != "totp") { + form.submit() + } } function addCsrf(form, token) { if (form["csrf_token"] == null) { diff --git a/web/webui/two_factor_controller.go b/web/webui/two_factor_controller.go index fa474703..f505bb3d 100644 --- a/web/webui/two_factor_controller.go +++ b/web/webui/two_factor_controller.go @@ -443,8 +443,8 @@ func OTPTokenIsExpired(tokenSentAt time.Time) bool { func UserGenerateTOTP(c *gin.Context) { req := NewRequest(c) user := req.CurrentUser - // if user.EncryptedAuthAppSecret == "" { - if 1 == 1 { + if user.EncryptedAuthAppSecret == "" { + // if 1 == 1 { secret, err := totp.Generate(totp.GenerateOpts{ Issuer: constants.TOTPSecretIssuer, AccountName: user.Email, @@ -454,15 +454,15 @@ func UserGenerateTOTP(c *gin.Context) { return } } - // user.EncryptedAuthAppSecret = secret.Secret() - // err = user.Save() - // if AbortIfError(c, err) { - // return - // } - req.TemplateData["sec"] = secret // temp - remove - } - // otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, user.EncryptedAuthAppSecret, constants.TOTPSecretIssuer) - otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, "nonesuch", constants.TOTPSecretIssuer) + user.EncryptedAuthAppSecret = secret.Secret() + err = user.Save() + if AbortIfError(c, err) { + return + } + // req.TemplateData["sec"] = secret // temp - remove + } + otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, user.EncryptedAuthAppSecret, constants.TOTPSecretIssuer) + //otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", constants.TOTPSecretIssuer, user.Email, "nonesuch", constants.TOTPSecretIssuer) png, err := qrcode.Encode(otpURL, qrcode.Medium, 256) if err != nil { if AbortIfError(c, err) { @@ -471,8 +471,8 @@ func UserGenerateTOTP(c *gin.Context) { } otpQRImage := base64.StdEncoding.EncodeToString(png) req.TemplateData["otpQRImage"] = otpQRImage - // req.TemplateData["secVal"] = user.EncryptedAuthAppSecret - req.TemplateData["secVal"] = "nonesuch" + req.TemplateData["secVal"] = user.EncryptedAuthAppSecret + // req.TemplateData["secVal"] = "nonesuch" c.HTML(http.StatusOK, "users/setup_authenticator_app.html", req.TemplateData) } @@ -485,18 +485,19 @@ func UserValidateTOTPView(c *gin.Context) { // Validates a TOTP provided by the user from their authenticator app. func UserValidateTOTP(c *gin.Context) { req := NewRequest(c) - // user := req.CurrentUser + user := req.CurrentUser totpCode := c.PostForm("totpCode") confirming := c.PostForm("firstConfirm") - // isValid := totp.Validate(totpCode, user.EncryptedAuthAppSecret) - isValid := totp.Validate(totpCode, "nonesuch") + isValid := totp.Validate(totpCode, user.EncryptedAuthAppSecret) + // isValid := totp.Validate(totpCode, "nonesuch") if !isValid { req.TemplateData["errorMessage"] = "Oops! That wasn't the right code. Please try again." c.HTML(http.StatusOK, "users/validate_totp.html", req.TemplateData) return } else { + // If we've reached here, you've provided a valid code if confirming == "" { - req.CurrentUser.AwaitingSecondFactor = false + // req.CurrentUser.AwaitingSecondFactor = false err := req.CurrentUser.Save() if err != nil { @@ -505,6 +506,10 @@ func UserValidateTOTP(c *gin.Context) { } } } + user.AwaitingSecondFactor = false + user.EnabledTwoFactor = true + user.ConfirmedTwoFactor = true + user.Save() helpers.SetFlashCookie(c, "Logged in successfully!") c.Redirect(http.StatusFound, "/dashboard") return From 8836b6b29ec33902f838854774098f91e6f3d05b Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Thu, 18 Sep 2025 17:33:12 -0400 Subject: [PATCH 05/10] Add TOTP validation page to whitelist if user has logged in with password but has not validated second factor yet --- middleware/authenticate.go | 1 + 1 file changed, 1 insertion(+) diff --git a/middleware/authenticate.go b/middleware/authenticate.go index d8164d24..1573a2a8 100644 --- a/middleware/authenticate.go +++ b/middleware/authenticate.go @@ -198,6 +198,7 @@ func forceCompletionOfTwoFactorAuth(c *gin.Context, currentUser *pgmodels.User) p := c.FullPath() return currentUser.ResetPasswordToken == "" && currentUser.AwaitingSecondFactor && + !strings.HasPrefix(p, "/users/validate_totp") && !strings.HasPrefix(p, "/users/2fa_backup") && !strings.HasPrefix(p, "/users/2fa_choose") && !strings.HasPrefix(p, "/users/2fa_push") && From f8b1bc28f072d0f1b5eb5f70152871bb3c54bb78 Mon Sep 17 00:00:00 2001 From: Melissa Iori Date: Tue, 23 Sep 2025 01:07:38 -0400 Subject: [PATCH 06/10] Minor fixes to messages and add ability to cancel auth app mfa enrollment --- views/users/setup_authenticator_app.html | 29 +++++++++++++++--------- views/users/validate_totp.html | 23 ++++++++++++------- web/webui/two_factor_controller.go | 16 ++++++++++--- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/views/users/setup_authenticator_app.html b/views/users/setup_authenticator_app.html index f7b482f5..a1eb02b8 100644 --- a/views/users/setup_authenticator_app.html +++ b/views/users/setup_authenticator_app.html @@ -5,28 +5,32 @@ {{ template "shared/_header.html" .}} {{ end }} -