TOTP Enrollment
++ Enroll in TOTP by adding the below secret to your TOTP manager and + verifying the code. +
+ {{ if .flashes.errors }} +{{ index .flashes.errors 0 }}
+diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo/v4 v4.13.3 github.com/lestrrat-go/jwx/v2 v2.0.12 github.com/multiformats/go-multihash v0.2.3 + github.com/pquerna/otp v1.5.0 github.com/samber/slog-echo v1.16.1 github.com/urfave/cli/v2 v2.27.6 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e @@ -37,6 +38,7 @@ require ( github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/carlmjohnson/versioninfo v0.22.5 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b h1:elwfbe+W7GkUmPKFX1h7HaeHvC/kC0XJWfiEHC62xPg= github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +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/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= @@ -289,6 +291,8 @@ 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/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +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 v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= diff --git a/models/models.go b/models/models.go --- a/models/models.go +++ b/models/models.go @@ -7,6 +7,13 @@ "github.com/bluesky-social/indigo/atproto/crypto" ) +type TwoFactorType string + +var ( + TwoFactorTypeNone = TwoFactorType("none") + TwoFactorTypeTotp = TwoFactorType("totp") +) + type Repo struct { Did string `gorm:"primaryKey"` CreatedAt time.Time @@ -23,6 +30,8 @@ SigningKey []byte Rev string Root []byte Preferences []byte + TwoFactorType TwoFactorType `gorm:"default:none"` + TotpSecret *string } func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) { diff --git a/server/handle_account_totp_enroll.go b/server/handle_account_totp_enroll.go new file mode 100644 --- /dev/null +++ b/server/handle_account_totp_enroll.go @@ -0,0 +1,100 @@ +package server + +import ( + "bytes" + "encoding/base64" + "fmt" + "image/png" + + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/models" + "github.com/labstack/echo/v4" + "github.com/pquerna/otp/totp" +) + +func (s *Server) handleAccountTotpEnrollGet(e echo.Context) error { + urepo, sess, err := s.getSessionRepoOrErr(e) + if err != nil { + return e.Redirect(303, "/account/signin") + } + + if urepo.TwoFactorType == models.TwoFactorTypeTotp { + sess.AddFlash("You have already enabled TOTP", "error") + sess.Save(e.Request(), e.Response()) + return e.Redirect(303, "/account") + } else if urepo.TwoFactorType != models.TwoFactorTypeNone { + sess.AddFlash("You have already have another 2FA method enabled", "error") + sess.Save(e.Request(), e.Response()) + return e.Redirect(303, "/account") + } + + secret, err := totp.Generate(totp.GenerateOpts{ + Issuer: s.config.Hostname, + AccountName: urepo.Repo.Did, + }) + if err != nil { + s.logger.Error("error generating totp secret", "error", err) + return helpers.ServerError(e, nil) + } + + sess.Values["totp-secret"] = secret + if err := sess.Save(e.Request(), e.Response()); err != nil { + s.logger.Error("error saving session", "error", err) + + return helpers.ServerError(e, nil) + } + + var buf bytes.Buffer + img, err := secret.Image(200, 200) + if err != nil { + s.logger.Error("error generating image from secret", "error", err) + return helpers.ServerError(e, nil) + } + png.Encode(&buf, img) + + b64img := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes())) + + return e.Render(200, "totp_enroll.html", map[string]any{ + "flashes": getFlashesFromSession(e, sess), + "Image": b64img, + }) +} + +type TotpEnrollRequest struct { + Code string `form:"code"` +} + +func (s *Server) handleAccountTotpEnrollPost(e echo.Context) error { + urepo, sess, err := s.getSessionRepoOrErr(e) + if err != nil { + return e.Redirect(303, "/account/signin") + } + + var req TotpEnrollRequest + if err := e.Bind(&req); err != nil { + s.logger.Error("error binding request for enroll totp", "error", err) + return helpers.ServerError(e, nil) + } + + secret, ok := sess.Values["totp-secret"].(string) + if !ok { + return helpers.InputError(e, nil) + } + + if !totp.Validate(req.Code, secret) { + sess.AddFlash("The provided code was not valid.", "error") + sess.Save(e.Request(), e.Response()) + return e.Redirect(303, "/account/totp-enroll") + } + + if err := s.db.Exec("UPDATE repos SET two_factor_type = ?, totp_secret = ? WHERE did = ?", nil, models.TwoFactorTypeTotp, secret, urepo.Repo.Did).Error; err != nil { + s.logger.Error("error updating database with totp token", "error", err) + return helpers.ServerError(e, nil) + } + + sess.AddFlash("You have successfully enrolled in TOTP!", "success") + delete(sess.Values, "totp-secret") + sess.Save(e.Request(), e.Response()) + + return e.Redirect(303, "/account") +} diff --git a/server/server.go b/server/server.go --- a/server/server.go +++ b/server/server.go @@ -690,6 +690,8 @@ s.echo.POST("/account/revoke", s.handleAccountRevoke) s.echo.GET("/account/signin", s.handleAccountSigninGet) s.echo.POST("/account/signin", s.handleAccountSigninPost) s.echo.GET("/account/signout", s.handleAccountSignout) + s.echo.GET("/account/totp-enroll", s.handleAccountTotpEnrollGet) + s.echo.POST("/account/totp-enroll", s.handleAccountTotpEnrollPost) // oauth account s.echo.GET("/oauth/jwks", s.handleOauthJwks) diff --git a/server/static/style.css b/server/static/style.css --- a/server/static/style.css +++ b/server/static/style.css @@ -81,3 +81,8 @@ .alert-danger { background-color: var(--danger); } + +.totp-image { + height: 200; + width: 200; +} diff --git a/server/templates/totp_enroll.html b/server/templates/totp_enroll.html new file mode 100644 --- /dev/null +++ b/server/templates/totp_enroll.html @@ -0,0 +1,30 @@ + + +
+ + + + + ++ Enroll in TOTP by adding the below secret to your TOTP manager and + verifying the code. +
+ {{ if .flashes.errors }} +{{ index .flashes.errors 0 }}
+